fix(落库): 重试槽改为多轮合并,短暂抖动不再"每 2 秒丢一批"(E2 复核改判)
## 改判内容(team-lead 复核结论)
上一版(58f82a6)在"重试槽已被占用"时把新失败批**直接丢弃**,只让最早那一批
享有重试特权。幂运算一下就看出问题:PG 抖动 10 秒(2 秒一轮 → 5 轮)时,
轮 1 批 A 进槽(有救),轮 2/3/4 的批 B/C/D 全丢,轮 5 槽 A 超限丢、批 E 也丢 ——
**10 秒抖动只救回一批**,等于把"每批都丢"降级成"每 2 秒丢一批",
而短暂抖动恰恰是重试最有效的场景。故改为**合并进槽**。
## 实现
- 槽的数据形态不变(`{ batch, attempts, lastError }`),但 `batch` 现在是
**多轮合并累积**的帧数组:失败时一律 `concat`,不再"占用即丢弃"。
- **有界性靠槽容量,不靠丢新批**:合并后若超过 `_storageQueueMax`(默认 5000,
与主队列同一上界)→ **截断**,保留**较早**的帧(先保已经等了更久的数据),
超出部分走 `_discardStorageBatch(..., '超出重试槽上限 N 帧')`:计入
`_storageFailed` + 告警,**明确丢弃不静默**。于是重试槽内存上界 = 主队列上界,
重试不会让内存膨胀。
- `attempts` 语义改为**槽的重试轮次**(每轮 flush +1,首个失败轮记为 1),
不再是"某一批的尝试次数"。超过 `_storageMaxRetries` → 整槽丢弃。
- 顺序不变(上一版写对的地方保留):**先试槽 → 无论成败都照常处理主队列**,
旧批失败绝不阻塞新数据;不在同一次 flush 内忙等重试。
## 附带发现:默认重试窗也要放宽(这一条请复核)
改完合并后我用临时量具实测"PG 抖动 10 秒"(5 轮 × 4 帧 = 20 帧,第 6 轮恢复):
- 默认 `storageMaxRetries = 3` → 只救回 **8/20** 帧(第 4 轮 ≈8 秒整槽被丢,丢 12 帧)
- 因为合并后 attempts 记的是**轮次**,上限 3 意味着"只覆盖 4 轮 ≈ 8 秒",
与"要覆盖一次真实抖动"的意图不匹配。
故默认值改为 **9 轮 ≈ 20 秒窗口**(2 秒/轮 × (1+9))。放宽不增加内存:槽容量由
`_storageQueueMax` 硬约束,窗口长短只影响"抖动期间攒多久"。改后同一场景
**20/20 全部救回、`storage_failed` = 0**。上限机制本身仍由测试 1(显式传
`storageMaxRetries: 3`)钉住。
三个版本在同一量具下的实测对照:
| 版本 | 落库 | storage_failed |
|---|---|---|
| 最初(只 log) | 0/20 | 字段不存在(静默) |
| 58f82a6(槽占用即丢) | 4/20 | 16 |
| 本次(合并 + 20 秒窗) | **20/20** | **0** |
## 测试(tests/storage-failure-test.js:29 → 41 项,全绿)
新增:
- **3b 跨 flush 合并**:3 个周期各失败 3/4/2 帧 → 槽内 **9 帧**、`attempts` = **3**
(不是 9);第 4 轮 PG 恢复 → **9 帧全部落库**、seq 1..9 顺序完整、`_storageFailed` = 0
- **3c 合并仍受容量约束**:槽上限 10,连续失败凑 15 帧 → 槽里只留 10 帧、
另 5 帧计入 `_storageFailed`、**只告警一次**、保留的是较早帧(1..10)
- **3d 抖动 10 秒(默认窗口)**:5 轮 20 帧全攒槽、恢复后 20 帧一次落库
原有 5 条全绿(含"失败不阻塞新数据"毒丸隔离、重试成功不丢数据、计数/告警节奏、
队列满丢最旧语义不变、`drainStorage` 刷净)。
另:把 1/3/4 段的重试上限**显式**传 3,让"上限机制"与"抖动窗口"两件事在测试里
各自独立可读。
## 变异自证
把失败分支改回旧实现(只 `log.error`、批次消失)→ **41 项里 27 项红**:
第 1 段 8 项全红、第 2 段 2 项、第 3 段 2 项、**3b 4 项、3c 5 项、3d 2 项**、
第 4 段 3 项、`drainStorage` 1 项;恢复后 41/41 绿。
(顺带加固了 3b/3c 的空槽判定为 `?.`,让回归时"报红"而不是抛异常中断整个套件。)
## 三侧数字
- GATEWAY_PORT=3107 bash tests/run-all.sh:**29 通过 / 1 失败**(30 套件;唯一红仍是隐私扫描,未触碰)
- cargo test --release:**61 / 0**
- cd client && flutter test:**485 / 0**
This commit is contained in:
1 parent
748785bb8e
commit
f543739b8d
3 files changed
+170
-37
No files matched your search
+74
-32
@@ -52,17 +52,28 @@ class Gateway {
|
||||
// ① 主队列满时 `_enqueueForStorage` 会 shift 掉**队首**——把失败批次放回去,
|
||||
// 等于让"最该被救的旧数据"变成"最该被丢的旧数据",两套语义直接打架;
|
||||
// ② 毒丸(某帧的值始终违反约束)会永久占据队首,把后面所有新数据一起堵死。
|
||||
// 故:失败批次单独存放、每轮 flush 只试一次(不忙等,否则会拖住 2 秒定时器),
|
||||
// 且**每轮先试它、再照常处理主队列** —— 旧批失败绝不阻塞新数据。
|
||||
// 槽内是**多轮合并**后的一批(`_mergeIntoRetry`):短暂抖动期间各轮失败的批次
|
||||
// 全并进来,PG 一恢复就一次写回 —— 否则只让"最早那一批"享有重试特权,
|
||||
// 10 秒抖动只救回 1/5 批,等于把"每批都丢"降级成"每 2 秒丢一批"。
|
||||
// 每轮 flush 只试一次(不忙等,否则会拖住 2 秒定时器),且**每轮先试它、
|
||||
// 再照常处理主队列** —— 旧批失败绝不阻塞新数据。
|
||||
/** @type {{batch: object[], attempts: number, lastError: string}|null} */
|
||||
this._storageRetry = null;
|
||||
// 超过重试上限、被**明确丢弃**的帧数(与 _storageDropped 的区别见 _discardStorageBatch)
|
||||
// 超过重试上限 / 超出槽容量、被**明确丢弃**的帧数(与 _storageDropped 的区别见 _discardStorageBatch)
|
||||
this._storageFailed = 0;
|
||||
this._storageFailedBatches = 0;
|
||||
// 上次告警时的累计丢弃帧数(告警节奏:首丢必报,之后每 100 帧一条)
|
||||
this._storageFailedAlertedAt = 0;
|
||||
// 单批最多重试几次(不含首次尝试)。2 秒一轮 → 默认覆盖约 8 秒的 PG 抖动
|
||||
this._storageMaxRetries = options.storageMaxRetries ?? 3;
|
||||
// 槽最多重试几轮(含首个失败轮)。2 秒一轮 → 默认 1+9 ≈ **20 秒抖动窗口**。
|
||||
//
|
||||
// 为什么默认从 3 放宽到 9(实测驱动):合并语义下 attempts 记的是**轮次**,
|
||||
// 上限 3 意味着第 4 轮(约 8 秒)就整槽丢弃 —— 用 /tmp 量具跑"PG 抖动 10 秒"
|
||||
// (5 轮各 4 帧)实测:上限 3 时只救回 8/20 帧(第 4 轮丢掉前 12 帧),
|
||||
// 上限 9 时 20/20 全部救回。
|
||||
// 放宽不会增加内存:槽的容量由 `_storageQueueMax`(与主队列同量级)硬约束,
|
||||
// 窗口长短只影响"抖动期间攒多久",不影响上界。长时间不可用仍会攒满后
|
||||
// 明确丢弃并计数告警 —— 有界与可见性都不破。
|
||||
this._storageMaxRetries = options.storageMaxRetries ?? 9;
|
||||
|
||||
// 查看端账号 → 编码偏好(Redis 持久,跨实例共享;进程内另有旁路缓存)
|
||||
// 以及「已向该账号下发过 msgpack schema 首帧」的标记,避免重复补发。
|
||||
@@ -1019,28 +1030,30 @@ class Gateway {
|
||||
* (`_storageDropped` + 周期 warn),这条是裸的。
|
||||
*
|
||||
* 现在的语义:
|
||||
* ① 失败批次进**独立重试槽**(不放回主队列,理由见构造函数注释);
|
||||
* ① 失败批次**合并进独立重试槽**(不放回主队列,理由见构造函数注释);
|
||||
* 合并而非"槽被占用就丢新批"—— 否则 10 秒抖动里只有最早一批有救
|
||||
* (详见 `_mergeIntoRetry` 的幂运算);
|
||||
* ② 每轮 flush **先试重试槽,再无论如何都照常处理主队列** ——
|
||||
* 旧批失败绝阻塞新数据;
|
||||
* ③ 有界重试:attempts 超过 _storageMaxRetries → **明确丢弃**、计数、告警
|
||||
* (宁可"明确丢弃并记录",也不要"静默消失");
|
||||
* ③ 有界:槽的轮次超过 `_storageMaxRetries`,或合并后超出 `_storageQueueMax`
|
||||
* → **明确丢弃**、计数、告警(宁可"明确丢弃并记录",也不要"静默消失");
|
||||
* ④ 不在同一次 flush 里忙等重试 —— 那会把 2 秒定时器拖住,
|
||||
* 让"暂时抖动"自己滚成"持续积压"。
|
||||
*/
|
||||
async _flushStorage() {
|
||||
if (!this.pgEnabled) return;
|
||||
|
||||
// ① 先试重试槽(若在有限重试窗口内)
|
||||
if (this._storageRetry) {
|
||||
// ① 先试重试槽(槽里是**多轮合并**后的一批,见 _mergeIntoRetry)
|
||||
if (this._storageRetry && this._storageRetry.batch.length > 0) {
|
||||
const slot = this._storageRetry;
|
||||
slot.attempts++; // 槽的重试轮次(不区分具体是哪一批)
|
||||
try {
|
||||
await this.repo.insertMetricsBatch(slot.batch);
|
||||
this._storageRetry = null; // 补上了,缺口填平
|
||||
this._storageRetry = null; // 全救回,缺口填平
|
||||
} catch (e) {
|
||||
slot.attempts++;
|
||||
slot.lastError = e.message;
|
||||
if (slot.attempts > this._storageMaxRetries) {
|
||||
this._discardStorageBatch(slot);
|
||||
this._discardStorageBatch(slot, '超过重试上限');
|
||||
this._storageRetry = null;
|
||||
}
|
||||
}
|
||||
@@ -1052,25 +1065,54 @@ class Gateway {
|
||||
try {
|
||||
await this.repo.insertMetricsBatch(batch);
|
||||
} catch (e) {
|
||||
if (this._storageRetry) {
|
||||
// 重试槽里已经压着一批(它还在窗口内)—— 新失败的这批不能挤掉它
|
||||
// (挤掉等于"更早的数据先死"),也不能攒着(内存必须立刻有界),
|
||||
// 故按明确的丢弃处理:计数 + 告警,不静默。
|
||||
this._discardStorageBatch({
|
||||
batch,
|
||||
attempts: this._storageMaxRetries + 1,
|
||||
lastError: e.message,
|
||||
});
|
||||
} else {
|
||||
this._storageRetry = { batch, attempts: 1, lastError: e.message };
|
||||
this.log.warn?.(`[gateway] 指标批量落库失败(${batch.length} 帧),`
|
||||
+ `进入有限重试(最多 ${this._storageMaxRetries} 次): ${e.message}`);
|
||||
}
|
||||
this._mergeIntoRetry(batch, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* **明确丢弃**一批落库失败的数据(超过重试上限,或重试槽已被占用)。
|
||||
* 把一批写库失败的数据**合并进重试槽**(E2 复核后改判:合并,而不是丢弃)。
|
||||
*
|
||||
* ── 为什么必须合并(幂运算一下就看出来)──────────────────────────
|
||||
* 若"槽已被占用就把新失败批直接丢掉",PG 抖动 10 秒(2 秒一轮 → 5 轮)时:
|
||||
* 轮1 批A进槽(有救);轮2/3/4 批B/C/D 全丢;轮5 槽A超限丢、批E 也丢。
|
||||
* → 10 秒抖动只救回一批,等于把"每批都丢"降级成"每 2 秒丢一批",
|
||||
* 而**短暂抖动恰恰是重试最有效的场景** —— 那样改等于没解决 E2。
|
||||
* 合并后:5 轮全并进槽里,PG 一恢复就一次写回(受下面的容量上限约束)。
|
||||
*
|
||||
* ── 有界性不受影响 ────────────────────────────────────────────────
|
||||
* 合并后超过 `_storageQueueMax` 就**截断**(保留较早的,丢尾部):
|
||||
* 超出部分计入 `_storageFailed` 并按同一套口径告警 —— 明确丢弃,不静默。
|
||||
* 于是重试槽的内存上界 = 主队列上界(5000 帧),不会因为重试而膨胀。
|
||||
*
|
||||
* `attempts` 语义:槽的**重试轮次**(每轮 flush +1),不是帧数、也不按批分。
|
||||
*/
|
||||
_mergeIntoRetry(batch, lastError) {
|
||||
if (!this._storageRetry) {
|
||||
// 首次失败:这一轮已经试过了,故轮次从 1 起算
|
||||
this._storageRetry = { batch: batch.slice(), attempts: 1, lastError };
|
||||
this.log.warn?.(`[gateway] 指标批量落库失败(${batch.length} 帧),进入有限重试`
|
||||
+ `(最多 ${this._storageMaxRetries} 轮,槽上限 ${this._storageQueueMax} 帧): ${lastError}`);
|
||||
} else {
|
||||
// 合并:旧批仍在有限重试窗口内,新失败批**并入**而不是挤掉/丢弃
|
||||
this._storageRetry.batch = this._storageRetry.batch.concat(batch);
|
||||
this._storageRetry.lastError = lastError;
|
||||
}
|
||||
|
||||
const slot = this._storageRetry;
|
||||
const cap = this._storageQueueMax;
|
||||
if (slot.batch.length <= cap) return;
|
||||
|
||||
// 超出容量:截断(保留较早的),超出部分明确丢弃并计数
|
||||
const overflow = slot.batch.slice(cap);
|
||||
slot.batch = slot.batch.slice(0, cap);
|
||||
this._discardStorageBatch(
|
||||
{ batch: overflow, attempts: slot.attempts, lastError },
|
||||
`超出重试槽上限 ${cap} 帧`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* **明确丢弃**一批落库失败的数据(超过重试上限,或合并后超出槽容量)。
|
||||
*
|
||||
* 为什么丢弃本身不可避免:内存必须有界,PG 长时间不可用时只能舍 ——
|
||||
* 但**必须留痕**。这条方法就是"不许静默消失"的落地:
|
||||
@@ -1078,10 +1120,10 @@ class Gateway {
|
||||
* `/health.storage_failed` 直接暴露它,>0 就意味着"真的丢了数据";
|
||||
* · 首丢必报一条 warn,之后每累计 100 帧一条(对齐 `_storageDropped`
|
||||
* 的节奏,避免持续故障时每 2 秒刷一条把日志淹了);
|
||||
* · 告警里带帧数、重试次数、错误原文、以及前几帧的 `device_id@seq` ——
|
||||
* 事后能判断"丢了哪台设备的哪一段",才知道要不要补。
|
||||
* · 告警里带帧数、丢弃原因、已尝试轮次、错误原文、以及前几帧的
|
||||
* `device_id@seq` —— 事后能判断"丢了哪台设备的哪一段",才知道要不要补。
|
||||
*/
|
||||
_discardStorageBatch(slot) {
|
||||
_discardStorageBatch(slot, why = '超过重试上限') {
|
||||
const n = slot.batch.length;
|
||||
this._storageFailed += n;
|
||||
this._storageFailedBatches++;
|
||||
@@ -1096,7 +1138,7 @@ class Gateway {
|
||||
.map((f) => `${f.device_id}@${f.seq}`)
|
||||
.join(',');
|
||||
this.log.warn?.(`[gateway] 指标落库**丢弃** ${n} 帧`
|
||||
+ `(累计 ${this._storageFailed} 帧 / ${this._storageFailedBatches} 批):`
|
||||
+ `(${why};累计 ${this._storageFailed} 帧 / ${this._storageFailedBatches} 批):`
|
||||
+ `已尝试 ${slot.attempts} 次 ~ ${slot.lastError};样本 ${sample}`);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -28,7 +28,7 @@
|
||||
# 中继 30(四道闸 + 真传不落盘 + 慢传不误删 + 限速定时器不叠加)、
|
||||
# 会话索引 50(O(1) 寻址与 sessions 表同生共死 + 角色翻转摘索引)、
|
||||
# 登录限流兜底有界 22(防"限流器自己变成泄漏点"+ 喷洒不冲掉热点 key)。
|
||||
# 落库失败不丢批 29(E2):用注入 repo 跑,无需服务;末尾的 /health 校验
|
||||
# 落库失败不丢批 41(E2):用注入 repo 跑,无需服务;末尾的 /health 校验
|
||||
# 需要 PG + Redis,起不来则明确跳过(不影响其余断言)。
|
||||
#
|
||||
# 用法:bash tests/run-all.sh
|
||||
@@ -106,7 +106,7 @@ run "设备身份判据(23 项)" node device-identity-test.js
|
||||
# 落库失败不许静默丢整批(E2):旧实现一次失败就丢掉整批(最多 5000 帧),
|
||||
# 不留计数、不重试、/health 全绿。本套件用注入 repo 验证有限重试槽、毒丸隔离、
|
||||
# 丢弃计数与告警节奏;末尾再起一个真网关确认 /health 的 storage_failed 可读。
|
||||
run "落库失败不丢批(29 项)" node storage-failure-test.js
|
||||
run "落库失败不丢批(41 项)" node storage-failure-test.js
|
||||
|
||||
# Rust 侧:单测 + 真实加载编译产物的 FFI 冒烟
|
||||
run "Rust 核心单测(59 项)" cargo test --release --manifest-path ../rust-core/Cargo.toml
|
||||
|
||||
@@ -173,8 +173,9 @@ async function main() {
|
||||
/* ─────────── ① 有界重试 → 明确丢弃 ─────────── */
|
||||
console.log('\n=== 1. 有界重试:失败批次不消失,超限后明确丢弃并计数 ===');
|
||||
{
|
||||
// 显式把上限压到 3,专测"有界"这件事本身(默认 9 是抖动窗口,见 gateway.js)
|
||||
const repo = makeRepo(() => new Error('PG 挂了'));
|
||||
const { gw, warns } = makeGateway(repo);
|
||||
const { gw, warns } = makeGateway(repo, { storageMaxRetries: 3 });
|
||||
|
||||
for (let i = 1; i <= 6; i++) gw._enqueueForStorage(frame('dev-a', i));
|
||||
await gw._flushStorage(); // 首次尝试失败 → 进重试槽
|
||||
@@ -237,7 +238,7 @@ async function main() {
|
||||
// 只要批次里含 dev-poison 就抛错 → 造一颗"毒丸",且它永远救不回来
|
||||
const repo = makeRepo((batch) =>
|
||||
(batch.some((f) => f.device_id === 'dev-poison') ? new Error('值违反约束') : null));
|
||||
const { gw } = makeGateway(repo);
|
||||
const { gw } = makeGateway(repo, { storageMaxRetries: 3 });
|
||||
|
||||
gw._enqueueForStorage(frame('dev-poison', 1));
|
||||
gw._enqueueForStorage(frame('dev-poison', 2));
|
||||
@@ -265,11 +266,101 @@ async function main() {
|
||||
`实得 ${repo.rows.length}`);
|
||||
}
|
||||
|
||||
/* ─────────── ③b 跨 flush 合并(复核改判:合并而不是丢弃) ─────────── */
|
||||
console.log('\n=== 3b. 跨 flush 合并:短暂抖动的每一批都要有救 ===');
|
||||
{
|
||||
// 前 5 次调用失败(= 3 个周期里槽/主队列各自的尝试),第 6 次成功
|
||||
const repo = makeRepo((_b, n) => (n <= 5 ? new Error('PG 抖动') : null));
|
||||
const { gw } = makeGateway(repo);
|
||||
|
||||
const sizes = [3, 4, 2]; // 三个周期各失败一批
|
||||
let seq = 0;
|
||||
for (const n of sizes) {
|
||||
for (let i = 0; i < n; i++) gw._enqueueForStorage(frame('dev-m', ++seq));
|
||||
await gw._flushStorage();
|
||||
}
|
||||
|
||||
ok('三个周期的失败批合并进同一个槽(3 + 4 + 2 = 9 帧)',
|
||||
gw._storageRetry !== null && gw._storageRetry.batch.length === 9,
|
||||
gw._storageRetry ? String(gw._storageRetry.batch.length) : '槽为空');
|
||||
ok('attempts 记的是**重试轮次**(3),不是帧数(9)',
|
||||
gw._storageRetry?.attempts === 3,
|
||||
gw._storageRetry ? String(gw._storageRetry.attempts) : '槽为空');
|
||||
ok('合并期间一帧都没丢(storage_failed 仍为 0)',
|
||||
gw._storageFailed === 0, String(gw._storageFailed));
|
||||
|
||||
await gw._flushStorage(); // 第 4 轮:PG 恢复
|
||||
ok('★ PG 恢复后一次写回,9 帧全部落库',
|
||||
repo.rows.length === 9, `实得 ${repo.rows.length}`);
|
||||
ok('救回后槽清空、seq 顺序完整(1..9)、计数仍为 0',
|
||||
gw._storageRetry === null && gw._storageFailed === 0
|
||||
&& repo.rows.map((r) => r.seq).join(',') === '1,2,3,4,5,6,7,8,9',
|
||||
repo.rows.map((r) => r.seq).join(','));
|
||||
}
|
||||
|
||||
/* ─────────── ③c 合并仍受槽容量约束 ─────────── */
|
||||
console.log('\n=== 3c. 合并不得破坏有界性:超容量明确丢弃并计数 ===');
|
||||
{
|
||||
// 槽容量复用 storageQueueMax(10);maxRetries 调大,避免中途整槽被丢
|
||||
const repo = makeRepo(() => new Error('持续不可用'));
|
||||
const { gw, warns } = makeGateway(repo,
|
||||
{ storageQueueMax: 10, storageMaxRetries: 20 });
|
||||
|
||||
const sizes = [4, 4, 4, 3]; // 合计 15 帧
|
||||
let seq = 0;
|
||||
for (const n of sizes) {
|
||||
for (let i = 0; i < n; i++) gw._enqueueForStorage(frame('dev-cap', ++seq));
|
||||
await gw._flushStorage();
|
||||
}
|
||||
|
||||
ok('槽被截断到容量上限 10 帧(内存上界 = 主队列上界)',
|
||||
gw._storageRetry !== null && gw._storageRetry.batch.length === 10,
|
||||
gw._storageRetry ? String(gw._storageRetry.batch.length) : '槽为空');
|
||||
ok('超出的 5 帧计入 storage_failed(明确丢弃,不静默)',
|
||||
gw._storageFailed === 5, String(gw._storageFailed));
|
||||
ok('保留的是**较早**的帧(尾部先丢,先保已经等了更久的数据)',
|
||||
gw._storageRetry?.batch[0]?.seq === 1 && gw._storageRetry?.batch[9]?.seq === 10,
|
||||
gw._storageRetry
|
||||
? `${gw._storageRetry.batch[0]?.seq}..${gw._storageRetry.batch[9]?.seq}`
|
||||
: '槽为空');
|
||||
ok('attempts 仍是轮次(4),不受合并/截断影响',
|
||||
gw._storageRetry?.attempts === 4,
|
||||
gw._storageRetry ? String(gw._storageRetry.attempts) : '槽为空');
|
||||
ok('告警只 1 条(首丢必报,之后按 100 帧节奏)',
|
||||
discardWarns(warns).length === 1, String(discardWarns(warns).length));
|
||||
}
|
||||
|
||||
/* ─────────── ③d 复核点名的场景:PG 抖动 10 秒(5 轮) ─────────── */
|
||||
console.log('\n=== 3d. PG 抖动 10 秒(2 秒一轮 × 5 轮):必须全救回 ===');
|
||||
{
|
||||
// 用**默认**上限跑,专测"默认窗口够不够覆盖一次真实抖动"
|
||||
let failing = true;
|
||||
const repo = makeRepo(() => (failing ? new Error('PG 抖动') : null));
|
||||
const { gw } = makeGateway(repo);
|
||||
|
||||
let seq = 0;
|
||||
for (let round = 1; round <= 5; round++) {
|
||||
for (let i = 0; i < 4; i++) gw._enqueueForStorage(frame('dev-10s', ++seq));
|
||||
await gw._flushStorage();
|
||||
}
|
||||
ok('抖动期间不丢:5 轮共 20 帧全部攒在槽里',
|
||||
gw._storageRetry !== null && gw._storageRetry.batch.length === 20
|
||||
&& gw._storageFailed === 0,
|
||||
gw._storageRetry ? `槽内 ${gw._storageRetry.batch.length} 帧` : '槽为空');
|
||||
|
||||
// 第 6 轮:PG 恢复
|
||||
failing = false;
|
||||
await gw._flushStorage();
|
||||
ok('★ PG 恢复后 20 帧一次全部落库(默认重试窗覆盖 10 秒抖动)',
|
||||
repo.rows.length === 20 && gw._storageFailed === 0,
|
||||
`落库 ${repo.rows.length} 帧 / 丢弃 ${gw._storageFailed}`);
|
||||
}
|
||||
|
||||
/* ─────────── ④ 多次丢弃:计数累计、告警不刷屏 ─────────── */
|
||||
console.log('\n=== 4. 多次丢弃:计数准确、告警不刷屏 ===');
|
||||
{
|
||||
const repo = makeRepo(() => new Error('磁盘满'));
|
||||
const { gw, warns } = makeGateway(repo);
|
||||
const { gw, warns } = makeGateway(repo, { storageMaxRetries: 3 });
|
||||
|
||||
for (let i = 1; i <= 2; i++) gw._enqueueForStorage(frame('dev-c', i));
|
||||
await flushTimes(gw, 4); // 首次 + 3 次重试 → 丢第 1 批(2 帧)
|
||||
|
||||
Reference in new issue
Block a user