@genee/omp-opsx-addon 0.1.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.
- package/README.md +83 -0
- package/index.ts +1327 -0
- package/lib/agent-defs.ts +291 -0
- package/lib/change-scanner.ts +72 -0
- package/lib/cursor-auth.ts +230 -0
- package/lib/direct-fetchers.ts +155 -0
- package/lib/edit-variant-pin.ts +156 -0
- package/lib/error-scan.ts +97 -0
- package/lib/family-filter.ts +174 -0
- package/lib/model-selector.ts +307 -0
- package/lib/model-tiers.ts +164 -0
- package/lib/notify.ts +4 -0
- package/lib/openspec-tasks.ts +28 -0
- package/lib/peak-detector.ts +44 -0
- package/lib/reachability-cache.ts +288 -0
- package/lib/reachability-probe.ts +459 -0
- package/lib/reachability-refresh.ts +84 -0
- package/lib/selection-filters.ts +100 -0
- package/lib/selection-lifecycle.ts +17 -0
- package/lib/session-title.ts +186 -0
- package/lib/session-usage.ts +252 -0
- package/lib/state.ts +95 -0
- package/lib/system-prompt.ts +160 -0
- package/lib/tiers-data.ts +136 -0
- package/lib/tiers-updater.ts +146 -0
- package/lib/unified-config.ts +463 -0
- package/lib/usage-estimator.ts +154 -0
- package/lib/usage-poller.ts +1200 -0
- package/lib/usage-poller.worker.ts +31 -0
- package/lib/usage-redis-client.ts +594 -0
- package/lib/usage-redis-multiproc.worker.ts +80 -0
- package/lib/usage-redis-probe.ts +389 -0
- package/lib/usage-render.ts +526 -0
- package/lib/usage-resolver.ts +477 -0
- package/lib/usage-sampler.ts +77 -0
- package/lib/usage-widget.ts +112 -0
- package/package.json +47 -0
- package/types/bun.d.ts +21 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concurrency-test worker PROCESS: several of these run simultaneously, each
|
|
3
|
+
* hammering `writeSessionUsage` for its own session id against the SAME
|
|
4
|
+
* REALTIME cache file. The poller's cross-process realtime file lock must
|
|
5
|
+
* serialize their read-modify-write cycles so no entry is clobbered (see the
|
|
6
|
+
* "file lock" describe in usage-poller.test.ts). Scoped to the realtime file
|
|
7
|
+
* lock only — the fetch path is not exercised here.
|
|
8
|
+
*
|
|
9
|
+
* Usage: bun usage-poller.worker.ts <realtimeCacheFile> <sessionId> <iters>
|
|
10
|
+
*/
|
|
11
|
+
import { _setRealtimeFileForTest, writeSessionUsage } from './usage-poller.js';
|
|
12
|
+
|
|
13
|
+
const [, , cacheFile, sessionId, itersStr] = process.argv;
|
|
14
|
+
const iters = Number(itersStr);
|
|
15
|
+
if (!cacheFile || !sessionId || !Number.isFinite(iters)) {
|
|
16
|
+
console.error('usage: usage-poller.worker.ts <realtimeCacheFile> <sessionId> <iters>');
|
|
17
|
+
process.exit(2);
|
|
18
|
+
}
|
|
19
|
+
_setRealtimeFileForTest(cacheFile);
|
|
20
|
+
// Mimic the recorder's contract: each write carries the session's FULL
|
|
21
|
+
// accumulated state (cumulative totals + per-second map keyed by absolute
|
|
22
|
+
// unix seconds), so the final disk entry must hold every second's key.
|
|
23
|
+
const seconds: Record<string, number> = {};
|
|
24
|
+
let total = 0;
|
|
25
|
+
for (let i = 0; i < iters; i++) {
|
|
26
|
+
total += 1;
|
|
27
|
+
seconds[String(1_000 + i)] = 1;
|
|
28
|
+
writeSessionUsage(sessionId, {
|
|
29
|
+
deepseek: { total, input: total, output: 0, seconds: { ...seconds } },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 最小 RESP2 Redis 客户端(零运行时依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* 1. RESP2 编解码:命令编码 `*<n>\r\n$<len>\r\n<bytes>\r\n...`,回复解析支持
|
|
6
|
+
* `+OK`/`-ERR`/`:int`/`$bulk`/`$-1`/`*array`(递归解析嵌套数组)
|
|
7
|
+
* 2. 单连接串行命令 + 简单 pipeline(一次连发、按发送顺序回读)
|
|
8
|
+
* 3. 连接超时(默认 300ms)与命令超时(默认 500ms)生效;超时/断连抛错不吞
|
|
9
|
+
* 4. transport 注入 seam(默认 `Bun.connect` 实现),测试可替换
|
|
10
|
+
*
|
|
11
|
+
* 设计(遵循 design.md D1/D5):
|
|
12
|
+
* - 命令面固定 6 条(PING/HINCRBY/HSET/HGETALL/DEL/EXPIRE)+ pipeline,无连接池/集群/哨兵
|
|
13
|
+
* - TCP 半包处理:`Buffer` 累积 + 按 `$len` 精确切包(bulk 内容不扫描),递归解析嵌套
|
|
14
|
+
* - 单连接串行:命令经内部 promise 链排队,任意时刻至多一个在途批次
|
|
15
|
+
* - 超时/断连/`close()` 后连接对象失效(dead),后续调用立即抛错,由上层降级
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ── 类型 ────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* RESP2 回复的运行时形态:
|
|
22
|
+
* - 简单字符串(`+OK` → `'OK'`)与 bulk 字符串 → `string`
|
|
23
|
+
* - 整数(`:n`)→ `number`
|
|
24
|
+
* - nil(`$-1` / `*-1`)→ `null`
|
|
25
|
+
* - 数组(`*n`,含嵌套)→ `RedisReply[]`
|
|
26
|
+
*/
|
|
27
|
+
export type RedisReply = string | number | null | RedisReply[];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Redis 协议/客户端错误。服务器返回 `-ERR` 回复时抛此类型。
|
|
31
|
+
*/
|
|
32
|
+
export class RedisError extends Error {
|
|
33
|
+
constructor(message: string) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'RedisError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 一个已建立的 TCP 连接的抽象(transport 注入 seam 的最小面)。 */
|
|
40
|
+
export interface RedisSocket {
|
|
41
|
+
/** 写入原始字节(RESP 编码后的命令)。 */
|
|
42
|
+
write(data: Uint8Array): void;
|
|
43
|
+
/** 注册数据回调:连接上到达的原始字节流。 */
|
|
44
|
+
onData(cb: (data: Uint8Array) => void): void;
|
|
45
|
+
/** 注册关闭回调:连接被对端关闭/断开。 */
|
|
46
|
+
onClose(cb: () => void): void;
|
|
47
|
+
/** 注册错误回调:连接级错误(如 ECONNRESET)。 */
|
|
48
|
+
onError(cb: (err: unknown) => void): void;
|
|
49
|
+
/** 关闭连接(客户端主动)。 */
|
|
50
|
+
end(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 连接工厂签名,测试 seam 可替换。默认实现为 `Bun.connect`。 */
|
|
54
|
+
export interface RedisTransport {
|
|
55
|
+
connect(opts: {
|
|
56
|
+
host: string;
|
|
57
|
+
port: number;
|
|
58
|
+
/** 连接超时 ms(默认 300,`redis_connect_timeout_ms` 语义)。 */
|
|
59
|
+
timeoutMs: number;
|
|
60
|
+
}): Promise<RedisSocket>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `RedisClient` 构造参数。 */
|
|
64
|
+
export interface RedisClientOptions {
|
|
65
|
+
/** Redis host,默认 `127.0.0.1`。 */
|
|
66
|
+
host?: string;
|
|
67
|
+
/** Redis port,默认 `6379`。 */
|
|
68
|
+
port?: number;
|
|
69
|
+
/** 连接超时 ms,默认 300。 */
|
|
70
|
+
connectTimeoutMs?: number;
|
|
71
|
+
/** 单命令往返超时 ms,默认 500。 */
|
|
72
|
+
commandTimeoutMs?: number;
|
|
73
|
+
/** 连接工厂(默认 `Bun.connect` 实现)。 */
|
|
74
|
+
transport?: RedisTransport;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── RESP2 命令编码 ──────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 将命令参数编码为 RESP2 数组帧:`*<n>\r\n$<len>\r\n<bytes>\r\n...`。
|
|
81
|
+
* 每个参数按 UTF-8 字节长度编码。
|
|
82
|
+
*/
|
|
83
|
+
export function encodeCommand(args: string[]): Buffer {
|
|
84
|
+
const parts: Buffer[] = [Buffer.from(`*${args.length}\r\n`)];
|
|
85
|
+
for (const arg of args) {
|
|
86
|
+
const bytes = Buffer.from(arg, 'utf8');
|
|
87
|
+
parts.push(Buffer.from(`$${bytes.length}\r\n`), bytes, Buffer.from('\r\n'));
|
|
88
|
+
}
|
|
89
|
+
return Buffer.concat(parts);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── RESP2 回复解析(Buffer 累积 + 按 $len 切包)────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* RESP2 回复解析器。
|
|
96
|
+
*
|
|
97
|
+
* 结构:累积缓冲 + 游标递归下降。
|
|
98
|
+
* - `push(chunk)` 把 TCP 到达的字节追加进累积缓冲(已消费前缀在下次 push 时回收)
|
|
99
|
+
* - `next()` 从游标处尝试解析一条完整回复;数据不足返回 `undefined`(半包待重组),
|
|
100
|
+
* 完整则提交游标并返回该回复;`-ERR` 回复抛 `RedisError`
|
|
101
|
+
* - bulk 按 `$len` 精确切包(内容字节不扫描 CRLF),数组递归解析嵌套回复
|
|
102
|
+
* - 仅当整条回复(含嵌套)解析成功才提交偏移,残缺帧不消费任何字节
|
|
103
|
+
*/
|
|
104
|
+
export class ReplyParser {
|
|
105
|
+
private buf: Buffer = Buffer.alloc(0);
|
|
106
|
+
private pos = 0;
|
|
107
|
+
|
|
108
|
+
/** 追加一段 TCP 数据。 */
|
|
109
|
+
push(chunk: Uint8Array): void {
|
|
110
|
+
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
111
|
+
if (this.pos > 0) {
|
|
112
|
+
this.buf = this.buf.subarray(this.pos);
|
|
113
|
+
this.pos = 0;
|
|
114
|
+
}
|
|
115
|
+
this.buf = this.buf.length === 0 ? data : Buffer.concat([this.buf, data]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 缓冲内是否还有未消费字节。 */
|
|
119
|
+
hasMore(): boolean {
|
|
120
|
+
return this.pos < this.buf.length;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 解析下一条完整回复。
|
|
125
|
+
* 返回 `undefined` 表示当前缓冲不足以构成一条完整回复(等更多数据)。
|
|
126
|
+
* 服务器返回 `-ERR` 时抛 `RedisError`。
|
|
127
|
+
*/
|
|
128
|
+
next(): RedisReply | undefined {
|
|
129
|
+
if (this.pos >= this.buf.length) return undefined;
|
|
130
|
+
const parsed = this.parseAt(this.buf, this.pos);
|
|
131
|
+
if (parsed === null) return undefined;
|
|
132
|
+
this.pos = parsed.end;
|
|
133
|
+
if (parsed.reply instanceof RedisError) throw parsed.reply;
|
|
134
|
+
return parsed.reply;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 从 `start` 偏移处解析一条回复;数据不足返回 `null`(不消费任何字节)。
|
|
139
|
+
* 遇到 `-ERR` 返回 `RedisError` 实例(帧已消费,由调用方在提交游标后抛)。
|
|
140
|
+
*/
|
|
141
|
+
private parseAt(
|
|
142
|
+
buf: Buffer,
|
|
143
|
+
start: number,
|
|
144
|
+
): { reply: RedisReply | RedisError; end: number } | null {
|
|
145
|
+
if (start >= buf.length) return null;
|
|
146
|
+
const type = buf[start];
|
|
147
|
+
const lineStart = start + 1;
|
|
148
|
+
const lineEnd = buf.indexOf('\r\n', lineStart);
|
|
149
|
+
if (lineEnd === -1) return null; // 行未到齐(半包)
|
|
150
|
+
const line = buf.toString('utf8', lineStart, lineEnd);
|
|
151
|
+
const afterLine = lineEnd + 2;
|
|
152
|
+
|
|
153
|
+
switch (type) {
|
|
154
|
+
case 0x2b /* + */:
|
|
155
|
+
return { reply: line, end: afterLine };
|
|
156
|
+
case 0x2d /* - */:
|
|
157
|
+
return { reply: new RedisError(line), end: afterLine };
|
|
158
|
+
case 0x3a /* : */: {
|
|
159
|
+
const n = Number.parseInt(line, 10);
|
|
160
|
+
if (!Number.isFinite(n)) throw new RedisError(`Malformed integer reply: ${line}`);
|
|
161
|
+
return { reply: n, end: afterLine };
|
|
162
|
+
}
|
|
163
|
+
case 0x24 /* $ */: {
|
|
164
|
+
const len = Number.parseInt(line, 10);
|
|
165
|
+
if (!Number.isFinite(len)) throw new RedisError(`Malformed bulk length: ${line}`);
|
|
166
|
+
if (len === -1) return { reply: null, end: afterLine };
|
|
167
|
+
if (len < -1) throw new RedisError(`Invalid bulk length: ${len}`);
|
|
168
|
+
if (buf.length < afterLine + len + 2) return null; // 内容未到齐(半包)
|
|
169
|
+
return {
|
|
170
|
+
reply: buf.toString('utf8', afterLine, afterLine + len),
|
|
171
|
+
end: afterLine + len + 2,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
case 0x2a /* * */: {
|
|
175
|
+
const n = Number.parseInt(line, 10);
|
|
176
|
+
if (!Number.isFinite(n)) throw new RedisError(`Malformed array length: ${line}`);
|
|
177
|
+
if (n === -1) return { reply: null, end: afterLine };
|
|
178
|
+
if (n < -1) throw new RedisError(`Invalid array length: ${n}`);
|
|
179
|
+
const items: RedisReply[] = [];
|
|
180
|
+
let p = afterLine;
|
|
181
|
+
for (let i = 0; i < n; i++) {
|
|
182
|
+
const item = this.parseAt(buf, p);
|
|
183
|
+
if (item === null) return null; // 嵌套回复未到齐
|
|
184
|
+
if (item.reply instanceof RedisError) return item; // 数组内错误回复中止整帧
|
|
185
|
+
items.push(item.reply);
|
|
186
|
+
p = item.end;
|
|
187
|
+
}
|
|
188
|
+
return { reply: items, end: p };
|
|
189
|
+
}
|
|
190
|
+
default:
|
|
191
|
+
throw new RedisError(`Unknown RESP type byte: 0x${type.toString(16)}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── 默认 transport(Bun.connect 实现)──────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Bun 运行时最小面(仓库未安装 bun-types,仅声明本模块用到的 `Bun.connect`)。
|
|
200
|
+
* 其余 Bun API 不在本模块使用。
|
|
201
|
+
*/
|
|
202
|
+
declare global {
|
|
203
|
+
var Bun: {
|
|
204
|
+
connect(opts: {
|
|
205
|
+
hostname: string;
|
|
206
|
+
port: number;
|
|
207
|
+
socket: {
|
|
208
|
+
data?(socket: unknown, data: Uint8Array): void;
|
|
209
|
+
open?(socket: unknown): void;
|
|
210
|
+
close?(socket: unknown, hadError: boolean): void;
|
|
211
|
+
end?(socket: unknown): void;
|
|
212
|
+
error?(socket: unknown, err: unknown): void;
|
|
213
|
+
};
|
|
214
|
+
}): Promise<{
|
|
215
|
+
write(data: Uint8Array | string): number;
|
|
216
|
+
flush(): void;
|
|
217
|
+
end(): void;
|
|
218
|
+
}>;
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** `Bun.connect` 默认 transport(带连接超时;超时后尽力回收迟到建立的连接)。 */
|
|
223
|
+
const defaultRedisTransport: RedisTransport = {
|
|
224
|
+
connect(opts): Promise<RedisSocket> {
|
|
225
|
+
return new Promise<RedisSocket>((resolve, reject) => {
|
|
226
|
+
let settled = false;
|
|
227
|
+
let dataCb: ((data: Uint8Array) => void) | null = null;
|
|
228
|
+
let closeCb: (() => void) | null = null;
|
|
229
|
+
let errorCb: ((err: unknown) => void) | null = null;
|
|
230
|
+
|
|
231
|
+
const socketPromise = Bun.connect({
|
|
232
|
+
hostname: opts.host,
|
|
233
|
+
port: opts.port,
|
|
234
|
+
socket: {
|
|
235
|
+
data(_s, data) {
|
|
236
|
+
dataCb?.(data);
|
|
237
|
+
},
|
|
238
|
+
close(_s) {
|
|
239
|
+
closeCb?.();
|
|
240
|
+
},
|
|
241
|
+
// 对端半关闭(FIN)同样视为连接结束,避免悬挂等待
|
|
242
|
+
end(_s) {
|
|
243
|
+
closeCb?.();
|
|
244
|
+
},
|
|
245
|
+
error(_s, err) {
|
|
246
|
+
errorCb?.(err);
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const timer = setTimeout(() => {
|
|
252
|
+
if (settled) return;
|
|
253
|
+
settled = true;
|
|
254
|
+
// 尽力回收超时后迟到建立的连接
|
|
255
|
+
socketPromise.then(
|
|
256
|
+
(s) => {
|
|
257
|
+
try {
|
|
258
|
+
s.end();
|
|
259
|
+
} catch {
|
|
260
|
+
/* ignore */
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
() => {
|
|
264
|
+
/* ignore */
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
reject(new Error(`Redis connect timeout after ${opts.timeoutMs}ms`));
|
|
268
|
+
}, opts.timeoutMs);
|
|
269
|
+
|
|
270
|
+
socketPromise.then(
|
|
271
|
+
(socket) => {
|
|
272
|
+
if (settled) return; // 连接超时已判定,socket 由定时器回收
|
|
273
|
+
settled = true;
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
resolve({
|
|
276
|
+
write(data) {
|
|
277
|
+
socket.write(data);
|
|
278
|
+
socket.flush();
|
|
279
|
+
},
|
|
280
|
+
onData(cb) {
|
|
281
|
+
dataCb = cb;
|
|
282
|
+
},
|
|
283
|
+
onClose(cb) {
|
|
284
|
+
closeCb = cb;
|
|
285
|
+
},
|
|
286
|
+
onError(cb) {
|
|
287
|
+
errorCb = cb;
|
|
288
|
+
},
|
|
289
|
+
end() {
|
|
290
|
+
socket.end();
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
},
|
|
294
|
+
(err) => {
|
|
295
|
+
if (settled) return;
|
|
296
|
+
settled = true;
|
|
297
|
+
clearTimeout(timer);
|
|
298
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// ── RedisClient ─────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 300;
|
|
308
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 500;
|
|
309
|
+
|
|
310
|
+
interface PendingRequest {
|
|
311
|
+
/** 待读回复数(单命令 1;pipeline 为命令数)。 */
|
|
312
|
+
count: number;
|
|
313
|
+
replies: RedisReply[];
|
|
314
|
+
resolve: (replies: RedisReply[]) => void;
|
|
315
|
+
reject: (err: Error) => void;
|
|
316
|
+
timer: NodeJS.Timeout | null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 最小 RESP2 Redis 客户端(单连接串行 + 简单 pipeline)。
|
|
321
|
+
*
|
|
322
|
+
* 生命周期:
|
|
323
|
+
* - 懒连接:首次命令触发 `transport.connect`(带连接超时)
|
|
324
|
+
* - 命令经内部 promise 链串行排队,任意时刻至多一个在途批次(含 pipeline)
|
|
325
|
+
* - 超时/断连/`close()` 后连接对象失效(dead),后续调用立即抛错
|
|
326
|
+
*
|
|
327
|
+
* 命令方法:`ping` / `hincrby` / `hset` / `hgetall` / `del` / `expire` + `pipeline`。
|
|
328
|
+
*/
|
|
329
|
+
export class RedisClient {
|
|
330
|
+
private readonly host: string;
|
|
331
|
+
private readonly port: number;
|
|
332
|
+
private readonly connectTimeoutMs: number;
|
|
333
|
+
private readonly commandTimeoutMs: number;
|
|
334
|
+
private readonly transport: RedisTransport;
|
|
335
|
+
|
|
336
|
+
private socket: RedisSocket | null = null;
|
|
337
|
+
private dead = false;
|
|
338
|
+
private readonly parser = new ReplyParser();
|
|
339
|
+
private pending: PendingRequest | null = null;
|
|
340
|
+
private chain: Promise<void> = Promise.resolve();
|
|
341
|
+
|
|
342
|
+
constructor(opts: RedisClientOptions = {}) {
|
|
343
|
+
this.host = opts.host ?? '127.0.0.1';
|
|
344
|
+
this.port = opts.port ?? 6379;
|
|
345
|
+
this.connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
346
|
+
this.commandTimeoutMs = opts.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
347
|
+
this.transport = opts.transport ?? defaultRedisTransport;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── 命令面 ────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
/** `PING` → `+PONG`;非 PONG 回复抛错。 */
|
|
353
|
+
async ping(): Promise<'PONG'> {
|
|
354
|
+
const reply = await this.execute(['PING']);
|
|
355
|
+
if (reply !== 'PONG') {
|
|
356
|
+
throw new RedisError(`PING returned unexpected reply: ${JSON.stringify(reply)}`);
|
|
357
|
+
}
|
|
358
|
+
return 'PONG';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** `HINCRBY key field delta` → 累加后的整数值。 */
|
|
362
|
+
async hincrby(key: string, field: string, delta: number): Promise<number> {
|
|
363
|
+
const reply = await this.execute(['HINCRBY', key, field, String(delta)]);
|
|
364
|
+
return assertInteger(reply, 'HINCRBY');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** `HSET key field value` → 本次新增的 field 数(覆盖已有 field 不计)。 */
|
|
368
|
+
async hset(key: string, field: string, value: string | number): Promise<number> {
|
|
369
|
+
const reply = await this.execute(['HSET', key, field, String(value)]);
|
|
370
|
+
return assertInteger(reply, 'HSET');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* `HGETALL key` → 全部 field/value。
|
|
375
|
+
* 空 hash 返回 `{}`;key 不存在(服务器回 nil/空数组)返回 `null`。
|
|
376
|
+
*/
|
|
377
|
+
async hgetall(key: string): Promise<Record<string, string> | null> {
|
|
378
|
+
const reply = await this.execute(['HGETALL', key]);
|
|
379
|
+
if (reply === null) return null;
|
|
380
|
+
if (!Array.isArray(reply)) {
|
|
381
|
+
throw new RedisError(`HGETALL returned non-array reply: ${JSON.stringify(reply)}`);
|
|
382
|
+
}
|
|
383
|
+
const out: Record<string, string> = {};
|
|
384
|
+
for (let i = 0; i + 1 < reply.length; i += 2) {
|
|
385
|
+
const field = reply[i];
|
|
386
|
+
const value = reply[i + 1];
|
|
387
|
+
if (typeof field === 'string' && typeof value === 'string') {
|
|
388
|
+
out[field] = value;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** `DEL key` → 被删除的 key 数。 */
|
|
395
|
+
async del(key: string): Promise<number> {
|
|
396
|
+
const reply = await this.execute(['DEL', key]);
|
|
397
|
+
return assertInteger(reply, 'DEL');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** `EXPIRE key ttlSec` → 1 已设 TTL / 0 未设置;兼容旧版 `+OK`(按 1 处理)。 */
|
|
401
|
+
async expire(key: string, ttlSec: number): Promise<number> {
|
|
402
|
+
const reply = await this.execute(['EXPIRE', key, String(ttlSec)]);
|
|
403
|
+
if (typeof reply === 'number') return reply;
|
|
404
|
+
if (reply === 'OK') return 1;
|
|
405
|
+
throw new RedisError(`EXPIRE returned unexpected reply: ${JSON.stringify(reply)}`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* 简单 pipeline:单连接一次连发所有命令,按发送顺序回读各条已解析回复。
|
|
410
|
+
* 返回数组与 `commands` 一一对应。
|
|
411
|
+
*/
|
|
412
|
+
pipeline(commands: Array<string[]>): Promise<RedisReply[]> {
|
|
413
|
+
if (commands.length === 0) return Promise.resolve([]);
|
|
414
|
+
return this.enqueue(async () => {
|
|
415
|
+
await this.ensureConnected();
|
|
416
|
+
return this.sendAndRead(commands, commands.length);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 释放连接;之后所有命令立即抛错。 */
|
|
421
|
+
close(): void {
|
|
422
|
+
this.failPending(new Error('Redis client closed'));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── 内部:串行化与在途请求 ─────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
private execute(args: string[]): Promise<RedisReply> {
|
|
428
|
+
if (args.length === 0) {
|
|
429
|
+
return Promise.reject(new RedisError('Empty command'));
|
|
430
|
+
}
|
|
431
|
+
return this.enqueue(async () => {
|
|
432
|
+
await this.ensureConnected();
|
|
433
|
+
const replies = await this.sendAndRead([args], 1);
|
|
434
|
+
return replies[0];
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** 命令 promise 链:任意时刻至多一个在途批次(单连接串行)。 */
|
|
439
|
+
private enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
|
440
|
+
const run = this.chain.then(fn);
|
|
441
|
+
this.chain = run.then(
|
|
442
|
+
() => undefined,
|
|
443
|
+
() => undefined,
|
|
444
|
+
);
|
|
445
|
+
return run;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private async ensureConnected(): Promise<void> {
|
|
449
|
+
if (this.socket) return;
|
|
450
|
+
if (this.dead) throw new Error('Redis client is closed; create a new client to reconnect');
|
|
451
|
+
let socket: RedisSocket;
|
|
452
|
+
try {
|
|
453
|
+
socket = await withTimeout(
|
|
454
|
+
this.transport.connect({ host: this.host, port: this.port, timeoutMs: this.connectTimeoutMs }),
|
|
455
|
+
this.connectTimeoutMs,
|
|
456
|
+
`Redis connect timeout after ${this.connectTimeoutMs}ms`,
|
|
457
|
+
);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
this.dead = true;
|
|
460
|
+
throw err;
|
|
461
|
+
}
|
|
462
|
+
this.socket = socket;
|
|
463
|
+
socket.onData((chunk) => this.onData(chunk));
|
|
464
|
+
socket.onClose(() => this.onSocketClosed());
|
|
465
|
+
socket.onError((err) => this.onSocketError(err));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private sendAndRead(cmds: Array<string[]>, count: number): Promise<RedisReply[]> {
|
|
469
|
+
const socket = this.socket;
|
|
470
|
+
if (!socket) {
|
|
471
|
+
return Promise.reject(new Error('Redis client is not connected'));
|
|
472
|
+
}
|
|
473
|
+
return new Promise<RedisReply[]>((resolve, reject) => {
|
|
474
|
+
this.pending = { count, replies: [], resolve, reject, timer: null };
|
|
475
|
+
this.armTimer();
|
|
476
|
+
try {
|
|
477
|
+
for (const cmd of cmds) {
|
|
478
|
+
socket.write(encodeCommand(cmd));
|
|
479
|
+
}
|
|
480
|
+
} catch (err) {
|
|
481
|
+
this.failPending(err);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private armTimer(): void {
|
|
487
|
+
const pending = this.pending;
|
|
488
|
+
if (!pending) return;
|
|
489
|
+
this.clearTimer(pending);
|
|
490
|
+
pending.timer = setTimeout(() => {
|
|
491
|
+
this.failPending(new Error(`Redis command timeout after ${this.commandTimeoutMs}ms`));
|
|
492
|
+
}, this.commandTimeoutMs);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private clearTimer(pending: PendingRequest): void {
|
|
496
|
+
if (pending.timer !== null) {
|
|
497
|
+
clearTimeout(pending.timer);
|
|
498
|
+
pending.timer = null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private onData(chunk: Uint8Array): void {
|
|
503
|
+
this.parser.push(chunk);
|
|
504
|
+
while (this.pending && this.parser.hasMore()) {
|
|
505
|
+
let reply: RedisReply | undefined;
|
|
506
|
+
try {
|
|
507
|
+
reply = this.parser.next();
|
|
508
|
+
} catch (err) {
|
|
509
|
+
// 单命令收到 `-ERR`:连接仍对齐,仅拒绝本次(连接存活)
|
|
510
|
+
if (err instanceof RedisError && this.pending && this.pending.count === 1) {
|
|
511
|
+
const pending = this.pending;
|
|
512
|
+
this.pending = null;
|
|
513
|
+
this.clearTimer(pending);
|
|
514
|
+
pending.reject(err);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
// 协议损坏 / pipeline 中途 `-ERR`:批次作废且连接不可继续信任
|
|
518
|
+
this.failPending(err);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (reply === undefined) break; // 半包,等更多数据
|
|
522
|
+
const pending = this.pending;
|
|
523
|
+
pending.replies.push(reply);
|
|
524
|
+
if (pending.replies.length >= pending.count) {
|
|
525
|
+
this.pending = null;
|
|
526
|
+
this.clearTimer(pending);
|
|
527
|
+
pending.resolve(pending.replies);
|
|
528
|
+
} else {
|
|
529
|
+
this.armTimer(); // 每收到一条回复滑动续期(单命令 = 仅一条)
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private failPending(err: unknown): void {
|
|
535
|
+
const pending = this.pending;
|
|
536
|
+
if (pending) {
|
|
537
|
+
this.pending = null;
|
|
538
|
+
this.clearTimer(pending);
|
|
539
|
+
pending.reject(err instanceof Error ? err : new Error(String(err)));
|
|
540
|
+
}
|
|
541
|
+
this.markDead();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private onSocketClosed(): void {
|
|
545
|
+
if (!this.dead) {
|
|
546
|
+
this.failPending(new Error('Redis connection closed unexpectedly'));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private onSocketError(err: unknown): void {
|
|
551
|
+
if (!this.dead) {
|
|
552
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
553
|
+
this.failPending(new Error(`Redis connection error: ${msg}`));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private markDead(): void {
|
|
558
|
+
this.dead = true;
|
|
559
|
+
if (this.socket) {
|
|
560
|
+
try {
|
|
561
|
+
this.socket.end();
|
|
562
|
+
} catch {
|
|
563
|
+
/* ignore */
|
|
564
|
+
}
|
|
565
|
+
this.socket = null;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── 工具 ────────────────────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
/** 在 `ms` 内未完成则 reject(连接超时兜底;默认 transport 内部亦自带超时)。 */
|
|
573
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
574
|
+
return new Promise<T>((resolve, reject) => {
|
|
575
|
+
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
576
|
+
promise.then(
|
|
577
|
+
(value) => {
|
|
578
|
+
clearTimeout(timer);
|
|
579
|
+
resolve(value);
|
|
580
|
+
},
|
|
581
|
+
(err) => {
|
|
582
|
+
clearTimeout(timer);
|
|
583
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
584
|
+
},
|
|
585
|
+
);
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function assertInteger(reply: RedisReply, cmd: string): number {
|
|
590
|
+
if (typeof reply !== 'number') {
|
|
591
|
+
throw new RedisError(`${cmd} returned non-integer reply: ${JSON.stringify(reply)}`);
|
|
592
|
+
}
|
|
593
|
+
return reply;
|
|
594
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-process Redis relay worker for the 4.7 concurrency test. N of these
|
|
3
|
+
* run simultaneously against ONE shared realtime cache file + ONE mock Redis
|
|
4
|
+
* TCP server, each with its own poller/relay instance (independent probe
|
|
5
|
+
* state, independent write-through mirrors, independent periodic reconcile).
|
|
6
|
+
*
|
|
7
|
+
* Usage: bun usage-redis-multiproc.worker.ts <realtimeFile> <sessionId> <port> <ttlMs> <mode>
|
|
8
|
+
* mode = 'write' → cumulative flush writes + event-level sec deltas
|
|
9
|
+
* mode = 'remove' → one write, then removeSessionUsage (session shutdown)
|
|
10
|
+
*/
|
|
11
|
+
import { dirname, join } from 'path';
|
|
12
|
+
import {
|
|
13
|
+
_setRealtimeFileForTest,
|
|
14
|
+
_setFetchFileForTest,
|
|
15
|
+
_setLegacyCacheFileForTest,
|
|
16
|
+
startSharedPoller,
|
|
17
|
+
writeSessionUsage,
|
|
18
|
+
removeSessionUsage,
|
|
19
|
+
mirrorSessionDelta,
|
|
20
|
+
} from './usage-poller.js';
|
|
21
|
+
|
|
22
|
+
const [, , realtimeFile, sessionId, portStr, ttlMsStr, mode] = process.argv;
|
|
23
|
+
const port = Number(portStr);
|
|
24
|
+
const ttlMs = Number(ttlMsStr);
|
|
25
|
+
if (!realtimeFile || !sessionId || !Number.isFinite(port) || !Number.isFinite(ttlMs)) {
|
|
26
|
+
console.error('usage: usage-redis-multiproc.worker.ts <realtimeFile> <sessionId> <port> <ttlMs> <mode>');
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
29
|
+
// Sandbox ALL cache paths: migrateLegacyCache must never read/write the real
|
|
30
|
+
// ~/.omp/cache files (a live old-code process keeps the legacy file fresh).
|
|
31
|
+
_setRealtimeFileForTest(realtimeFile);
|
|
32
|
+
_setFetchFileForTest(join(dirname(realtimeFile), 'fetch.json'));
|
|
33
|
+
_setLegacyCacheFileForTest(join(dirname(realtimeFile), 'legacy.json'));
|
|
34
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
35
|
+
const provider = `provider-${sessionId}`;
|
|
36
|
+
// Frozen seconds in the recent past: reconcile covers them while the worker is
|
|
37
|
+
// alive; the test asserts file == redis on exactly these keys.
|
|
38
|
+
const baseSec = Math.floor(Date.now() / 1000) - 10;
|
|
39
|
+
const frozenSeconds = { [String(baseSec)]: 1, [String(baseSec + 1)]: 2 };
|
|
40
|
+
|
|
41
|
+
await startSharedPoller({ list: () => [] }, [], undefined, {
|
|
42
|
+
renderTickMs: 20,
|
|
43
|
+
tickMs: 60_000,
|
|
44
|
+
idleDirtyMs: 60_000,
|
|
45
|
+
redis: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
host: '127.0.0.1',
|
|
48
|
+
port,
|
|
49
|
+
connectTimeoutMs: 300,
|
|
50
|
+
commandTimeoutMs: 300,
|
|
51
|
+
reconcileIntervalMs: 250,
|
|
52
|
+
probeIntervalMs: 500,
|
|
53
|
+
unhealthyRetryMs: 150,
|
|
54
|
+
ttlMs,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (mode === 'write') {
|
|
59
|
+
const ITERS = 20;
|
|
60
|
+
for (let i = 1; i <= ITERS; i++) {
|
|
61
|
+
const secNow = String(Math.floor(Date.now() / 1000));
|
|
62
|
+
// Event-level mirror (HINCRBY) on the current second — exercises the
|
|
63
|
+
// concurrent-writer path on shared sec keys across processes.
|
|
64
|
+
mirrorSessionDelta(sessionId, provider, secNow, 1);
|
|
65
|
+
writeSessionUsage(sessionId, {
|
|
66
|
+
[provider]: { total: i, input: i, output: 0, seconds: frozenSeconds },
|
|
67
|
+
});
|
|
68
|
+
await sleep(30);
|
|
69
|
+
}
|
|
70
|
+
await sleep(400); // drain fire-and-forget mirrors + a final reconcile
|
|
71
|
+
console.log('WORKER_DONE');
|
|
72
|
+
process.exit(0); // the poller's renderTimer keeps the event loop alive
|
|
73
|
+
} else {
|
|
74
|
+
writeSessionUsage(sessionId, { [provider]: { total: 1, input: 1, output: 0 } });
|
|
75
|
+
await sleep(150);
|
|
76
|
+
removeSessionUsage(sessionId);
|
|
77
|
+
await sleep(300); // drain the DEL (or its failure) before exiting
|
|
78
|
+
console.log('WORKER_DONE');
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|