@coclaw/openclaw-coclaw 0.19.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * runId → connId 路由表(已集成于 realtime-bridge)
3
+ *
4
+ * 用途:把 OpenClaw gateway 推来的 `event:agent` 帧按 runId 单播给真正发起这个 run 的 DC,
5
+ * 避免多 PC 场景下"广播给所有连过来的 rpc DC"导致死 PC 也收到的问题。
6
+ *
7
+ * 设计要点:
8
+ * - 构造函数纯组装,无副作用;起 timer 走 init(),停 timer 走 destroy()
9
+ * - destroy 后 add / remove / lookup / clear / init 全是 no-op
10
+ * - add 写入策略:runId 不在表写入;同 reqId 刷新 expireAt;不同 reqId 跳过覆盖(首发优先)
11
+ * - remove 删除策略:runId 在表 + entry.reqId === 入参 reqId 才删(防跨 RPC 巧合 runId 误删)
12
+ * - lookup hot path:不顺手清过期,TTL 由 scan timer 负责
13
+ * - scan 整段 try/catch 兜底,logger 抛错也不能击穿 gateway 进程
14
+ * - timer 必须 unref()——避免 hold 进程退出
15
+ *
16
+ * 与 reqId 路由表(realtime-bridge.js __dcPendingRequests)保持行为对齐:
17
+ * 对 PC 关闭不做联动清理,TTL 兜底;网关 WS 断开走 clear()。
18
+ */
19
+
20
+ /** 路由条目最大存活时间(24h),与 reqId 表对齐 */
21
+ export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
22
+ /** 整表周期扫描间隔(1h),与 reqId 表对齐 */
23
+ export const DEFAULT_SCAN_MS = 60 * 60 * 1000;
24
+
25
+ export class RunEventRoutes {
26
+ /**
27
+ * @param {object} [opts]
28
+ * @param {{ info?: Function, warn?: Function, error?: Function, debug?: Function }} [opts.logger=console]
29
+ * @param {number} [opts.ttlMs=DEFAULT_TTL_MS]
30
+ * @param {number} [opts.scanMs=DEFAULT_SCAN_MS]
31
+ */
32
+ constructor({ logger, ttlMs, scanMs } = {}) {
33
+ this.logger = logger ?? console;
34
+ this.ttlMs = ttlMs ?? DEFAULT_TTL_MS;
35
+ this.scanMs = scanMs ?? DEFAULT_SCAN_MS;
36
+ /** @type {Map<string, { connId: string, reqId: string, expireAt: number }>} */
37
+ this.__entries = new Map();
38
+ this.__scanTimer = null;
39
+ this.__destroyed = false;
40
+ }
41
+
42
+ /** 起周期扫描 timer。重入安全:已 init / 已 destroy 均 no-op */
43
+ init() {
44
+ if (this.__destroyed) return;
45
+ if (this.__scanTimer) return;
46
+ this.__scanTimer = setInterval(() => this.__scanExpired(), this.scanMs);
47
+ this.__scanTimer.unref?.();
48
+ }
49
+
50
+ /**
51
+ * 添加路由条目。任一参数 falsy 静默返回(防御性)。
52
+ * @param {string} runId
53
+ * @param {string} connId
54
+ * @param {string} reqId
55
+ */
56
+ add(runId, connId, reqId) {
57
+ if (this.__destroyed) return;
58
+ if (!runId || !connId || !reqId) return;
59
+ const existing = this.__entries.get(runId);
60
+ const expireAt = Date.now() + this.ttlMs;
61
+ if (existing && existing.reqId !== reqId) {
62
+ // 已被首发占用,跳过覆盖(防 attach 抢路由)。debug 日志便于观察。
63
+ // logger.debug 自身抛错也不能让 add 失败(与 __scanExpired 内的 try/catch 风格一致)
64
+ try { this.logger.debug?.(`[run-event-routes] add skipped: runId already routed by reqId=${existing.reqId} new reqId=${reqId}`); }
65
+ catch { /* logger 自身坏了不能让 add 抛 */ }
66
+ return;
67
+ }
68
+ // 同 reqId 重发:仅刷新 expireAt,connId 锁死在首发值(首发优先的彻底化)
69
+ if (existing) {
70
+ existing.expireAt = expireAt;
71
+ return;
72
+ }
73
+ this.__entries.set(runId, { connId, reqId, expireAt });
74
+ }
75
+
76
+ /**
77
+ * 移除路由条目。runId 在表且 entry.reqId === 入参 reqId 才删。
78
+ * @param {string} runId
79
+ * @param {string} reqId
80
+ */
81
+ remove(runId, reqId) {
82
+ if (this.__destroyed) return;
83
+ if (!runId || !reqId) return;
84
+ const entry = this.__entries.get(runId);
85
+ if (!entry) return;
86
+ if (entry.reqId !== reqId) return;
87
+ this.__entries.delete(runId);
88
+ }
89
+
90
+ /**
91
+ * 查路由 → connId 或 undefined。不顺手清过期。
92
+ * @param {string} runId
93
+ * @returns {string | undefined}
94
+ */
95
+ lookup(runId) {
96
+ if (this.__destroyed) return undefined;
97
+ if (!runId) return undefined;
98
+ const entry = this.__entries.get(runId);
99
+ return entry?.connId;
100
+ }
101
+
102
+ /** 整表清空。不动 timer(语义=网关 WS 断开后清表,保留 scan 给后续 init 用)*/
103
+ clear() {
104
+ if (this.__destroyed) return;
105
+ this.__entries.clear();
106
+ }
107
+
108
+ /** 停 timer + clear + 标 destroyed。幂等。*/
109
+ destroy() {
110
+ if (this.__destroyed) return;
111
+ this.__destroyed = true;
112
+ if (this.__scanTimer) {
113
+ clearInterval(this.__scanTimer);
114
+ this.__scanTimer = null;
115
+ }
116
+ this.__entries.clear();
117
+ }
118
+
119
+ /** 内部扫描过期条目;try/catch 兜底,避免 timer 回调异常击穿 gateway 进程 */
120
+ __scanExpired() {
121
+ try {
122
+ const now = Date.now();
123
+ let cleaned = 0;
124
+ for (const [runId, entry] of this.__entries) {
125
+ if (entry.expireAt <= now) {
126
+ this.__entries.delete(runId);
127
+ cleaned += 1;
128
+ }
129
+ }
130
+ if (cleaned > 0) {
131
+ this.logger.warn?.(`[run-event-routes] expired entries cleaned: count=${cleaned}`);
132
+ }
133
+ }
134
+ catch {
135
+ // 扫描器自身异常静默吞掉,避免拖垮 gateway(如 logger.warn 抛错)
136
+ }
137
+ }
138
+ }
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs';
2
- import os from 'node:os';
3
2
  import nodePath from 'node:path';
4
3
 
4
+ import { agentSessionsDir, sessionStorePath, sessionTranscriptPath } from '../claw-paths.js';
5
+
5
6
  const DERIVED_TITLE_MAX_LEN = 60;
6
7
 
7
8
  // OC 注入的 inbound metadata 头部(Conversation info / Sender / Thread starter 等)
@@ -172,18 +173,22 @@ function deriveTitle(filePath, logger) {
172
173
  }
173
174
 
174
175
  export function createSessionManager(options = {}) {
175
- const rootDir = options.rootDir ?? nodePath.join(os.homedir(), '.openclaw', 'agents');
176
176
  /* c8 ignore next */
177
177
  const logger = options.logger ?? console;
178
+ const resolveSessionsDir = options.resolveSessionsDir ?? agentSessionsDir;
179
+ const resolveStorePath = options.resolveStorePath ?? sessionStorePath;
180
+ const resolveTranscriptPath = options.resolveTranscriptPath ?? sessionTranscriptPath;
178
181
 
179
182
  function sessionsDir(agentId = 'main') {
180
183
  /* c8 ignore next */
181
184
  const aid = typeof agentId === 'string' && agentId.trim() ? agentId.trim() : 'main';
182
- return nodePath.join(rootDir, aid, 'sessions');
185
+ return resolveSessionsDir(aid);
183
186
  }
184
187
 
185
188
  function readIndex(agentId = 'main') {
186
- const file = nodePath.join(sessionsDir(agentId), 'sessions.json');
189
+ /* c8 ignore next */
190
+ const aid = typeof agentId === 'string' && agentId.trim() ? agentId.trim() : 'main';
191
+ const file = resolveStorePath(aid);
187
192
  const data = readJsonSafe(file, {});
188
193
  /* c8 ignore next */
189
194
  if (!data || typeof data !== 'object') return {};
@@ -279,7 +284,7 @@ export function createSessionManager(options = {}) {
279
284
  const dir = sessionsDir(agentId);
280
285
  // live 文件优先:同一 sessionId 可能同时存在 live 和 reset 文件
281
286
  // (OpenClaw reset 后复用 sessionId),live 代表当前活跃 transcript
282
- const livePath = nodePath.join(dir, `${sessionId}.jsonl`);
287
+ const livePath = resolveTranscriptPath(sessionId, agentId);
283
288
  if (fs.existsSync(livePath)) {
284
289
  return livePath;
285
290
  }
package/src/settings.js CHANGED
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import nodePath from 'node:path';
4
4
 
5
- import { resolveStateDir, CHANNEL_ID } from './config.js';
5
+ import { pluginDir } from './claw-paths.js';
6
6
  import { atomicWriteJsonFile } from './utils/atomic-write.js';
7
7
  import { createMutex } from './utils/mutex.js';
8
8
 
@@ -12,7 +12,7 @@ export const MAX_NAME_LENGTH = 63;
12
12
  const settingsMutex = createMutex();
13
13
 
14
14
  function getSettingsPath() {
15
- return nodePath.join(resolveStateDir(), CHANNEL_ID, SETTINGS_FILENAME);
15
+ return nodePath.join(pluginDir(), SETTINGS_FILENAME);
16
16
  }
17
17
 
18
18
  async function readJsonSafe(filePath) {
@@ -1,8 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
- import os from 'node:os';
3
2
  import nodePath from 'node:path';
4
3
  import { randomUUID } from 'node:crypto';
5
4
 
5
+ import { agentSessionsDir } from '../claw-paths.js';
6
6
  import { atomicWriteJsonFile } from '../utils/atomic-write.js';
7
7
  import { createMutex } from '../utils/mutex.js';
8
8
 
@@ -21,16 +21,15 @@ function emptyStore() {
21
21
  export class TopicManager {
22
22
  /**
23
23
  * @param {object} [opts]
24
- * @param {string} [opts.rootDir] - agents 根目录,默认 ~/.openclaw/agents
25
24
  * @param {object} [opts.logger]
25
+ * @param {Function} [opts.resolveSessionsDir] - 测试注入:自定义 sessions 目录解析
26
26
  * @param {Function} [opts.readFile] - 测试注入
27
27
  * @param {Function} [opts.writeJsonFile] - 测试注入
28
28
  * @param {Function} [opts.unlinkFile] - 测试注入
29
29
  * @param {Function} [opts.copyFile] - 测试注入
30
30
  */
31
31
  constructor(opts = {}) {
32
- /* c8 ignore next 6 -- ?? fallback:测试始终注入 */
33
- this.__rootDir = opts.rootDir ?? nodePath.join(os.homedir(), '.openclaw', 'agents');
32
+ this.__resolveSessionsDir = opts.resolveSessionsDir ?? agentSessionsDir;
34
33
  this.__logger = opts.logger ?? console;
35
34
  this.__readFile = opts.readFile ?? fs.readFile;
36
35
  this.__writeJsonFile = opts.writeJsonFile ?? atomicWriteJsonFile;
@@ -45,7 +44,7 @@ export class TopicManager {
45
44
  }
46
45
 
47
46
  __sessionsDir(agentId) {
48
- return nodePath.join(this.__rootDir, agentId, 'sessions');
47
+ return this.__resolveSessionsDir(agentId);
49
48
  }
50
49
 
51
50
  __topicsFilePath(agentId) {
@@ -1,18 +1,19 @@
1
1
  /**
2
2
  * 纯内存版 FBQ-API 兼容容器
3
3
  *
4
- * 阶段 1 用作 RpcDcSender 的前置缓冲,替换原 `RpcSendQueue` 中的"容器层"职责(admission +
5
- * overflow 边沿状态机 + close 汇总)。阶段 2 再把本模块替换为 `FileBackedQueue`,因接口对齐,
6
- * 替换近乎一行 import 改动。
4
+ * 阶段 1 用作 RpcDcSender 的前置缓冲,与 `FileBackedQueue` 接口对齐。阶段 2 单点平替,
5
+ * webrtc-peer 仅一行 `new` 改写。
7
6
  *
8
7
  * 与 FBQ 的差异:
9
8
  * - 不引入 fs;`diskBytes / writtenBytes` 永为 0,`spilled / fsBroken` 永为 false
10
- * - admission 仅基于 `memBudget`;命中 `bypassAdmission(jsonStr)` 时即使队列满也接收(保留
11
- * `RpcSendQueue` 的 agent run 白名单豁免行为)
12
- * - 内部携带 overflow-start/-end 边沿状态机和 close 汇总日志(搬自原 `RpcSendQueue`)
9
+ * - admission 仅基于 `memBudget`;命中 `bypassAdmission(jsonStr)` 时即使队列满也接收
13
10
  *
14
- * 契约:`enqueue / __nextIter / destroy / clear` 内部任何分支都不得因 logger / remoteLog / onDrop /
15
- * bypassAdmission 自身抛而传染调用方——所有外部调用均经过 safe wrapper(try/catch)。
11
+ * 容器纯净化(B-stage1):本模块不承担诊断职责(边沿日志、累计、汇总),仅通过
12
+ * `onDrop(reason, size)` 回调把丢弃事件外抛,由调用方(rpc-drop-monitor 等)统一
13
+ * 处理日志输出。容器与诊断解耦后 MemoryQueue ≡ FBQ minus 磁盘语义。
14
+ *
15
+ * 契约:`enqueue / __nextIter / destroy / clear` 内部任何分支都不得因 onDrop /
16
+ * bypassAdmission 自身抛而传染调用方——所有外部回调均经过 try/catch 包裹。
16
17
  *
17
18
  * 使用方式(消费侧):
18
19
  * ```js
@@ -21,7 +22,6 @@
21
22
  * 调用 `queue.destroy()` 后 iterator 在下一轮返回 `{ done: true }`。
22
23
  */
23
24
 
24
- import { remoteLog } from '../remote-log.js';
25
25
  import { createMutex } from './mutex.js';
26
26
 
27
27
  /** 默认内存预算:与原 RpcSendQueue 的 MAX_QUEUE_BYTES 对齐 */
@@ -85,11 +85,6 @@ class MemoryQueue {
85
85
  this.destroyed = false;
86
86
  this.waiters = [];
87
87
  this.mutex = createMutex();
88
-
89
- // drop 统计 + overflow 边沿状态机
90
- this.droppedCount = 0;
91
- this.droppedBytes = 0;
92
- this.queueOverflowActive = false;
93
88
  }
94
89
 
95
90
  /**
@@ -107,15 +102,21 @@ class MemoryQueue {
107
102
 
108
103
  /**
109
104
  * 入队一条字符串。
110
- * - 队列满(memBytes >= memBudget)且未命中 bypassAdmission onDrop + 返回 false
111
- * 首次进入溢出态打 overflow-start(warn + remoteLog),持续期间静默累加
105
+ * - destroyed 直接返回 false(**silent 短路**,**不触发 onDrop**)。设计意图:destroyed
106
+ * 意味着对应连接已死/正在清理,丢弃是正确清理副作用,不需要 noisy 日志。loud-on-loss
107
+ * 红线只对"连接活着但拒收"场景生效(oversize / queue-full)。
108
+ * - 单条 size > maxMessageBytes(bypass 也不豁免)→ onDrop('oversize', size) + 返回 false
109
+ * - 队列满(memBytes >= memBudget)且未命中 bypassAdmission → onDrop('queue-full', size) + 返回 false
112
110
  * - 否则入队 + 返回 true(包括"单条 overshoot":当前 memBytes < memBudget,但本条很大)
113
111
  *
112
+ * 不输出任何日志/累计:诊断职责由调用方注入的 onDrop 处理。
113
+ *
114
114
  * @param {string} jsonStr
115
115
  * @returns {Promise<boolean>}
116
116
  */
117
117
  async enqueue(jsonStr) {
118
118
  return await this.mutex.withLock(async () => {
119
+ // destroyed 短路:silent,不调 onDrop。详见上方 JSDoc。
119
120
  if (this.destroyed) return false;
120
121
  if (typeof jsonStr !== 'string') throw new TypeError('jsonStr must be a string');
121
122
 
@@ -124,10 +125,7 @@ class MemoryQueue {
124
125
  // per-message 硬上限:bypass 也不豁免。对齐 sender 端 MAX_SINGLE_MSG_BYTES 检查,
125
126
  // 避免大帧先入队再被 sender 拒,导致 memBytes 异常膨胀(特别是 sender 阻塞期间)。
126
127
  if (size > this.maxMessageBytes) {
127
- this.droppedCount += 1;
128
- this.droppedBytes += size;
129
128
  this.__dispatchDrop('oversize', size);
130
- this.__safeWarn(`drop reason=oversize size=${size} cap=${this.maxMessageBytes}`);
131
129
  return false;
132
130
  }
133
131
 
@@ -135,15 +133,7 @@ class MemoryQueue {
135
133
  // 允许"单条 overshoot":上一条消息把 queueBytes 顶到 < MAX 但 >= MAX 之间任一值时
136
134
  // 仍能入队;下一次再有非白名单消息才会被 drop。
137
135
  if (this.memBytes >= this.memBudget && !this.__isBypass(jsonStr)) {
138
- this.droppedCount += 1;
139
- this.droppedBytes += size;
140
136
  this.__dispatchDrop('queue-full', size);
141
- // 仅状态翻转点打 log,避免 DC 卡死时刷屏
142
- if (!this.queueOverflowActive) {
143
- this.queueOverflowActive = true;
144
- this.__safeWarn(`overflow-start queueBytes=${this.memBytes}`);
145
- this.__safeRemoteLog(`rpc-queue.overflow-start${this.__tagSuffix()} queueBytes=${this.memBytes}`);
146
- }
147
137
  return false;
148
138
  }
149
139
 
@@ -155,11 +145,10 @@ class MemoryQueue {
155
145
  }
156
146
 
157
147
  /**
158
- * 当前快照,用于诊断 dump。形态与 FBQ 对齐 + 阶段 1 私有诊断字段。
148
+ * 当前快照,用于诊断 dump。形态与 FBQ 完全对齐(6 字段)。
159
149
  * @returns {{
160
150
  * memCount: number, memBytes: number, diskBytes: number, writtenBytes: number,
161
- * spilled: boolean, fsBroken: boolean,
162
- * droppedCount: number, droppedBytes: number, queueOverflowActive: boolean
151
+ * spilled: boolean, fsBroken: boolean
163
152
  * }}
164
153
  */
165
154
  stats() {
@@ -170,14 +159,11 @@ class MemoryQueue {
170
159
  writtenBytes: 0,
171
160
  spilled: false,
172
161
  fsBroken: false,
173
- droppedCount: this.droppedCount,
174
- droppedBytes: this.droppedBytes,
175
- queueOverflowActive: this.queueOverflowActive,
176
162
  };
177
163
  }
178
164
 
179
165
  /**
180
- * 清空数据但保留实例可用,重置 drop 统计与 overflow 状态。
166
+ * 清空数据但保留实例可用。
181
167
  */
182
168
  async clear() {
183
169
  return await this.mutex.withLock(async () => {
@@ -185,22 +171,38 @@ class MemoryQueue {
185
171
  this.memQueue = [];
186
172
  this.head = 0;
187
173
  this.memBytes = 0;
188
- this.droppedCount = 0;
189
- this.droppedBytes = 0;
190
- this.queueOverflowActive = false;
191
174
  });
192
175
  }
193
176
 
194
177
  /**
195
- * 关闭队列:唤醒所有 waiter(让 iterator 返回 done)、汇总 drop/residual log。幂等。
178
+ * 关闭队列:唤醒所有 waiter(让 iterator 返回 done)。幂等。
179
+ * 不输出汇总日志:close 汇总职责由调用方注入的 monitor.summarize 处理。
180
+ *
181
+ * @param {(residual: { memCount: number, memBytes: number, diskBytes: number, writtenBytes: number, spilled: boolean, fsBroken: boolean }) => void} [onBeforeClear]
182
+ * 可选回调(**必须为同步函数**):在 mutex 内、清空 memQueue **之前**触发,参数是销毁时刻的残留快照。
183
+ * 存在意义:mutex 保证 in-flight enqueue 已落地(broadcast 是 fire-and-forget),调用方
184
+ * sync 调 `queue.stats()` 读不到 in-flight 入队的消息;改用此回调可拿到原子准确的残留快照。
185
+ * 回调同步抛错被 swallow,不影响 destroy 完成。
186
+ * **注意**:返回 Promise 的异步回调其 rejection 不会被捕获——仅设计为 sync 钩子。
196
187
  */
197
- async destroy() {
188
+ async destroy(onBeforeClear) {
198
189
  return await this.mutex.withLock(async () => {
199
190
  if (this.destroyed) return;
200
191
  this.destroyed = true;
201
192
 
202
- const residual = this.memQueue.length - this.head;
203
- const residualBytes = this.memBytes;
193
+ // mutex 内、清空之前快照残留:保证看到所有已入队的消息(含 in-flight)
194
+ if (typeof onBeforeClear === 'function') {
195
+ const residual = {
196
+ memCount: this.memQueue.length - this.head,
197
+ memBytes: this.memBytes,
198
+ diskBytes: 0,
199
+ writtenBytes: 0,
200
+ spilled: false,
201
+ fsBroken: false,
202
+ };
203
+ try { onBeforeClear(residual); }
204
+ catch { /* 回调自身抛是调用方的 bug;不能传染给 destroy 契约 */ }
205
+ }
204
206
 
205
207
  // 唤醒所有等待者,让它们在下一轮循环看到 destroyed 并返回 done
206
208
  const toWake = this.waiters.splice(0);
@@ -209,12 +211,6 @@ class MemoryQueue {
209
211
  this.memQueue = [];
210
212
  this.head = 0;
211
213
  this.memBytes = 0;
212
-
213
- if (this.droppedCount > 0 || residual > 0) {
214
- this.__safeRemoteLog(
215
- `rpc-queue.close${this.__tagSuffix()} dropped=${this.droppedCount} droppedBytes=${this.droppedBytes} residualChunks=${residual} residualBytes=${residualBytes}`,
216
- );
217
- }
218
214
  });
219
215
  }
220
216
 
@@ -243,14 +239,6 @@ class MemoryQueue {
243
239
  this.head = 0;
244
240
  }
245
241
 
246
- // 满 → 未满 状态翻转:与 overflow-start 对称,含累计 dropped
247
- if (this.queueOverflowActive && this.memBytes < this.memBudget) {
248
- this.queueOverflowActive = false;
249
- this.__safeInfo(`overflow-end dropped=${this.droppedCount} droppedBytes=${this.droppedBytes}`);
250
- this.__safeRemoteLog(
251
- `rpc-queue.overflow-end${this.__tagSuffix()} dropped=${this.droppedCount} droppedBytes=${this.droppedBytes}`,
252
- );
253
- }
254
242
  return { value: item, done: false };
255
243
  }
256
244
  if (this.destroyed) return { done: true, value: undefined };
@@ -297,14 +285,6 @@ class MemoryQueue {
297
285
  __safeWarn(msg) {
298
286
  try { this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] ${msg}`); } catch { /* logger 自身坏了也不能让 enqueue 抛 */ }
299
287
  }
300
-
301
- __safeInfo(msg) {
302
- try { this.logger.info?.(`[rpc-queue${this.__tagSuffix()}] ${msg}`); } catch { /* logger 自身坏了也不能让 enqueue/__nextIter 抛 */ }
303
- }
304
-
305
- __safeRemoteLog(text) {
306
- try { remoteLog(text); } catch { /* 防御性:remoteLog 当前同步路径不抛,未来若变化此 wrapper 兜底 */ }
307
- }
308
288
  }
309
289
 
310
290
  export { MemoryQueue };
@@ -0,0 +1,154 @@
1
+ /**
2
+ * rpc DC 发送链路的"丢弃监视器"——独立模块,与队列容器解耦。
3
+ *
4
+ * 职责:承接 MemoryQueue / FileBackedQueue 的 onDrop 事件,做边沿状态机日志、
5
+ * 累计计数、关闭汇总。容器(队列)只负责数据缓冲,不知道日志策略;监视器只
6
+ * 知道事件,不知道队列内部结构。两者通过 onDrop(reason, size, err?) 契约对接。
7
+ *
8
+ * 设计要点:
9
+ * - 工厂函数 + 闭包,不是 class(与项目其它工具模块一致)
10
+ * - 5 个内部标量 + 1 个 idempotent flag 跟踪状态
11
+ * - logger / remoteLog 调用一律 try/catch 包裹,自身抛不传染调用方
12
+ * - maybeEmitOverflowEnd 反抖动(候选 A):仅当 memCount===0 && writtenBytes===0 才翻转
13
+ * B-stage1 阶段 writtenBytes 恒 0(MemoryQueue),等价 memCount===0
14
+ * B-stage2 切 FBQ 时同一行代码自然抗"刚出列又写盘"的抖动
15
+ *
16
+ * 日志格式约定(与现行 MemoryQueue 内嵌日志保持一致;唯一漂移点是
17
+ * overflow-start 的 queueBytes token:现行取 memBytes,本模块取被拒消息 size,
18
+ * 因为监视器不持队列深度——属可接受漂移):
19
+ *
20
+ * warn / remoteLog: rpc-queue.overflow-start conn=X queueBytes=N (queue-full)
21
+ * warn / remoteLog: rpc-queue.disk-cap-start conn=X size=N (disk-cap)
22
+ * warn / remoteLog: rpc-queue.fs-broken conn=X errno=X msg= (fs-error,sticky)
23
+ * warn: [rpc-queue conn=X] oversize size=N (每条独立)
24
+ * info / remoteLog: rpc-queue.overflow-end conn=X dropped=N droppedBytes=M
25
+ * remoteLog: rpc-queue.close conn=X dropped=N droppedBytes=M
26
+ * residualChunks=K residualBytes=L
27
+ * residualDiskBytes=X residualWrittenBytes=Y
28
+ * fsBroken=bool lastReason=str
29
+ *
30
+ * close 日志含两组 disk token(residualDiskBytes/residualWrittenBytes)是为 FBQ 阶段
31
+ * 诊断完整性预留——B-stage1 阶段它们恒 0;B-stage2 切到 FileBackedQueue 时承载磁盘残留信息。
32
+ */
33
+
34
+ import { remoteLog } from '../remote-log.js';
35
+
36
+ /**
37
+ * @param {object} opts
38
+ * @param {string} opts.connId - 连接 ID,用于日志 conn=${connId} token
39
+ * @param {{ warn?: Function, info?: Function, error?: Function }} opts.logger
40
+ * @returns {{
41
+ * onDrop: (reason: string, size: number, err?: { code?: string, message?: string }) => void,
42
+ * maybeEmitOverflowEnd: (stats: { memCount: number, writtenBytes: number }) => void,
43
+ * summarize: (residualStats?: { memCount?: number, memBytes?: number, diskBytes?: number, writtenBytes?: number }) => void,
44
+ * getStats: () => { dropCount: number, dropBytes: number, overflowActive: boolean, fsBroken: boolean, lastReason: string|null },
45
+ * }}
46
+ */
47
+ export function createRpcDropMonitor({ connId, logger }) {
48
+ let dropCount = 0;
49
+ let dropBytes = 0;
50
+ let overflowActive = false;
51
+ let fsBroken = false; // sticky:一旦 true 不复位
52
+ let lastReason = null;
53
+ let summarized = false; // summarize 幂等 flag
54
+
55
+ const tag = `[rpc-queue conn=${connId}]`;
56
+
57
+ function safeWarn(msg) {
58
+ try { logger?.warn?.(`${tag} ${msg}`); }
59
+ catch { /* logger 自身坏了也不能让 onDrop 抛 */ }
60
+ }
61
+
62
+ function safeInfo(msg) {
63
+ try { logger?.info?.(`${tag} ${msg}`); }
64
+ catch { /* 同上 */ }
65
+ }
66
+
67
+ function safeRemoteLog(text) {
68
+ try { remoteLog(text); }
69
+ catch { /* remoteLog 当前同步路径不抛;防御性兜底 */ }
70
+ }
71
+
72
+ function onDrop(reason, size, err) {
73
+ dropCount += 1;
74
+ dropBytes += size;
75
+ lastReason = reason;
76
+
77
+ if (reason === 'queue-full') {
78
+ if (!overflowActive) {
79
+ overflowActive = true;
80
+ safeWarn(`overflow-start queueBytes=${size}`);
81
+ safeRemoteLog(`rpc-queue.overflow-start conn=${connId} queueBytes=${size}`);
82
+ }
83
+ // 持续期间静默
84
+ return;
85
+ }
86
+ if (reason === 'disk-cap') {
87
+ if (!overflowActive) {
88
+ overflowActive = true;
89
+ safeWarn(`disk-cap-start size=${size}`);
90
+ safeRemoteLog(`rpc-queue.disk-cap-start conn=${connId} size=${size}`);
91
+ }
92
+ return;
93
+ }
94
+ if (reason === 'fs-error') {
95
+ if (!fsBroken) {
96
+ fsBroken = true;
97
+ const errno = err?.code ?? 'UNKNOWN';
98
+ const errMsg = err?.message ?? '';
99
+ safeWarn(`fs-broken errno=${errno} msg=${errMsg}`);
100
+ safeRemoteLog(`rpc-queue.fs-broken conn=${connId} errno=${errno} msg=${errMsg}`);
101
+ }
102
+ return;
103
+ }
104
+ if (reason === 'oversize') {
105
+ // 每次独立 warn,不改 overflowActive
106
+ safeWarn(`oversize size=${size}`);
107
+ return;
108
+ }
109
+ // 未知 reason:仅累加,无 log(防御性)
110
+ }
111
+
112
+ function maybeEmitOverflowEnd(stats) {
113
+ if (!overflowActive) return;
114
+ if (!stats) return; // 防御:调用方应传 queue.stats(),但 stats 为 null/undefined 时安全跳过
115
+ if (stats.memCount === 0 && stats.writtenBytes === 0) {
116
+ overflowActive = false;
117
+ safeInfo(`overflow-end dropped=${dropCount} droppedBytes=${dropBytes}`);
118
+ safeRemoteLog(`rpc-queue.overflow-end conn=${connId} dropped=${dropCount} droppedBytes=${dropBytes}`);
119
+ }
120
+ }
121
+
122
+ function summarize(residualStats) {
123
+ if (summarized) return;
124
+ summarized = true;
125
+
126
+ const residualChunks = residualStats?.memCount ?? 0;
127
+ const residualBytes = residualStats?.memBytes ?? 0;
128
+ const residualDiskBytes = residualStats?.diskBytes ?? 0;
129
+ const residualWrittenBytes = residualStats?.writtenBytes ?? 0;
130
+ const hasAnomaly = overflowActive || fsBroken || dropCount > 0
131
+ || residualChunks > 0 || residualDiskBytes > 0 || residualWrittenBytes > 0;
132
+ if (hasAnomaly) {
133
+ safeRemoteLog(
134
+ `rpc-queue.close conn=${connId} dropped=${dropCount} droppedBytes=${dropBytes}`
135
+ + ` residualChunks=${residualChunks} residualBytes=${residualBytes}`
136
+ + ` residualDiskBytes=${residualDiskBytes} residualWrittenBytes=${residualWrittenBytes}`
137
+ + ` fsBroken=${fsBroken} lastReason=${lastReason ?? 'none'}`,
138
+ );
139
+ }
140
+ overflowActive = false;
141
+ }
142
+
143
+ function getStats() {
144
+ return {
145
+ dropCount,
146
+ dropBytes,
147
+ overflowActive,
148
+ fsBroken,
149
+ lastReason,
150
+ };
151
+ }
152
+
153
+ return { onDrop, maybeEmitOverflowEnd, summarize, getStats };
154
+ }