@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,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis probe 状态机 + write-through 镜像 + prefetch/reconcile。
|
|
3
|
+
*
|
|
4
|
+
* 全局聚合语义(跨 session 累加在写入侧完成,展示侧只读不聚合):
|
|
5
|
+
* 1. probe 状态机:`unknown` 立即 / `healthy` 每 `probeIntervalMs` 心跳 / `unhealthy`
|
|
6
|
+
* 每 `unhealthyRetryMs` 退避重试,全部绝对时间门控(由 poller 1s tick 驱动,无新 timer)
|
|
7
|
+
* 2. write-through 镜像(全 fire-and-forget:同步返回、绝不 throw、绝不重试,失败只置
|
|
8
|
+
* `mirrorFailed`,由 `observeMirrorFailure` 观察后翻 `unhealthy`):
|
|
9
|
+
* - `onDelta`:秒级事件级 `HINCRBY opsx:usage:sec:<abssec> <provider> <delta>` +
|
|
10
|
+
* `EXPIRE`(跨 session 在写入时天然累加)
|
|
11
|
+
* - `onTotalsDelta`:累计级 `HINCRBY opsx:usage:totals <provider>:total|input|output`
|
|
12
|
+
* (无 TTL——totals 是永久累计)
|
|
13
|
+
* 3. prefetch:healthy 时每 tick pipeline 读秒窗口(nowSec-60 .. nowSec-2,2s 滞后)的
|
|
14
|
+
* 全部 sec key + `opsx:usage:totals`,解析成 `RedisSnapshot`;totals 在 Redis 缺失时
|
|
15
|
+
* 用文件值兜底(Redis 重启的 ≤30s 空窗);失败翻 unhealthy 并 rethrow(poller 回退文件)
|
|
16
|
+
* 4. reconcile:healthy 时逐 key 覆盖——`HSET opsx:usage:totals`(文件全量)+ 冻结秒
|
|
17
|
+
* `HSET opsx:usage:sec:<sec> <provider> <值>` + `EXPIRE`,跳过当前进行中秒;禁止增量
|
|
18
|
+
* 合并;无 DEL+整体重建、无 owner 选举、无锁
|
|
19
|
+
*
|
|
20
|
+
* 语义硬约束:
|
|
21
|
+
* - key 结构 `opsx:usage:sec:<abssec>`(field `<provider>`)与 `opsx:usage:totals`
|
|
22
|
+
* (field `<provider>:total|input|output`)
|
|
23
|
+
* - `ttlSec = round(ttlMs / 1000)` 只作用于 sec key;totals 永久
|
|
24
|
+
* - client 懒建:命令前若 `client == null` 则经 `clientFactory` 新建(失败后旧 client
|
|
25
|
+
* 失效置 null,下次命令重建)
|
|
26
|
+
* - await 路径失败 → close + 置 null + 翻 unhealthy;fire-and-forget 失败 → 只置 mirrorFailed
|
|
27
|
+
* - provider 含 `:` → 写入侧防御跳过该次镜像并置 mirrorFailed;解析侧防御跳过(防 id
|
|
28
|
+
* 引入 `:` 导致 field 错位)
|
|
29
|
+
* - 不新增 timer/interval(所有节奏门控由 poller 的现有 1s tick 驱动)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { RedisClient, type RedisReply, type RedisTransport } from './usage-redis-client.js';
|
|
33
|
+
|
|
34
|
+
// ── 类型 ────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/** probe 状态机:内存态,不持久化(重启回 `unknown`,下个 tick 立即探测)。 */
|
|
37
|
+
export type RedisState = 'unknown' | 'healthy' | 'unhealthy';
|
|
38
|
+
|
|
39
|
+
/** `RedisRelay` 构造参数。 */
|
|
40
|
+
export interface RedisRelayOptions {
|
|
41
|
+
host: string;
|
|
42
|
+
port: number;
|
|
43
|
+
/** 连接超时 ms(透传 `RedisClient`)。 */
|
|
44
|
+
connectTimeoutMs: number;
|
|
45
|
+
/** 单命令往返超时 ms(透传 `RedisClient`)。 */
|
|
46
|
+
commandTimeoutMs: number;
|
|
47
|
+
/** healthy 心跳 PING 周期 ms(默认 30000)。 */
|
|
48
|
+
probeIntervalMs: number;
|
|
49
|
+
/** unhealthy 退避重试 PING 周期 ms(默认 5000)。 */
|
|
50
|
+
unhealthyRetryMs: number;
|
|
51
|
+
/** reconcile 周期 ms(默认 30000)。 */
|
|
52
|
+
reconcileIntervalMs: number;
|
|
53
|
+
/** sec key 的 TTL ms(默认 180000)。 */
|
|
54
|
+
ttlMs: number;
|
|
55
|
+
/** 连接工厂透传(测试 seam)。 */
|
|
56
|
+
transport?: RedisTransport;
|
|
57
|
+
/** 测试 seam:绝对时间源(默认 `Date.now`)。 */
|
|
58
|
+
now?: () => number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 结构化 realtime 权威数据:GLOBAL cross-session aggregates(poller 的
|
|
63
|
+
* `RealtimeFile` 自动兼容)。
|
|
64
|
+
*/
|
|
65
|
+
export interface RealtimeLike {
|
|
66
|
+
/** provider → 累计三元组。 */
|
|
67
|
+
totals?: Record<string, { total: number; input: number; output: number }>;
|
|
68
|
+
/** unix second → provider → tokens(全局和)。 */
|
|
69
|
+
seconds?: Record<string, Record<string, number>>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** prefetch 返回的 Redis 快照(全局聚合,可直接渲染)。 */
|
|
73
|
+
export interface RedisSnapshot {
|
|
74
|
+
/** provider → bucket。 */
|
|
75
|
+
totals: Record<string, { total: number; input: number; output: number }>;
|
|
76
|
+
/** provider → {secStr: tokens}。 */
|
|
77
|
+
perSecond: Record<string, Record<string, number>>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── field 解析(对 `:` 前置假设防御)────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/** 解析 totals field `<provider>:total|input|output`;非三者或段空则跳过。 */
|
|
83
|
+
function parseTotalsField(
|
|
84
|
+
field: string,
|
|
85
|
+
value: string,
|
|
86
|
+
): { provider: string; metric: 'total' | 'input' | 'output'; num: number } | null {
|
|
87
|
+
const parts = field.split(':');
|
|
88
|
+
if (parts.length !== 2) return null;
|
|
89
|
+
const [provider, metric] = parts;
|
|
90
|
+
if (!provider || !metric) return null;
|
|
91
|
+
if (metric !== 'total' && metric !== 'input' && metric !== 'output') return null;
|
|
92
|
+
const num = Number(value);
|
|
93
|
+
if (!Number.isFinite(num)) return null;
|
|
94
|
+
return { provider, metric, num };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── RedisRelay ──────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Redis probe 状态机 + write-through 镜像 + prefetch/reconcile(见文件头)。
|
|
101
|
+
*
|
|
102
|
+
* 不新增 timer:所有 tick 步骤由 poller 的现有 1s `renderTimer` firing 内绝对时间门控调用。
|
|
103
|
+
*/
|
|
104
|
+
export class RedisRelay {
|
|
105
|
+
private readonly host: string;
|
|
106
|
+
private readonly port: number;
|
|
107
|
+
private readonly connectTimeoutMs: number;
|
|
108
|
+
private readonly commandTimeoutMs: number;
|
|
109
|
+
private readonly probeIntervalMs: number;
|
|
110
|
+
private readonly unhealthyRetryMs: number;
|
|
111
|
+
private readonly reconcileIntervalMs: number;
|
|
112
|
+
private readonly ttlSec: number;
|
|
113
|
+
private readonly transport: RedisTransport | undefined;
|
|
114
|
+
private readonly now: () => number;
|
|
115
|
+
private readonly clientFactory: () => RedisClient;
|
|
116
|
+
|
|
117
|
+
/** 懒建 client:命令前若为 null 则经 `clientFactory` 新建;失败后 close + 置 null。 */
|
|
118
|
+
private client: RedisClient | null = null;
|
|
119
|
+
private _state: RedisState = 'unknown';
|
|
120
|
+
|
|
121
|
+
/** 任一镜像写(fire-and-forget)失败/超时置 true;`observeMirrorFailure` 消费后翻 unhealthy。 */
|
|
122
|
+
mirrorFailed = false;
|
|
123
|
+
/** 最近一次探测尝试的绝对时间戳(含失败尝试)。 */
|
|
124
|
+
lastProbeAt = 0;
|
|
125
|
+
/** 最近一次成功 reconcile 的绝对时间戳;0 = 从未 reconcile(首 tick 立即 reconcile)。 */
|
|
126
|
+
lastReconcileAt = 0;
|
|
127
|
+
|
|
128
|
+
constructor(opts: RedisRelayOptions) {
|
|
129
|
+
this.host = opts.host;
|
|
130
|
+
this.port = opts.port;
|
|
131
|
+
this.connectTimeoutMs = opts.connectTimeoutMs;
|
|
132
|
+
this.commandTimeoutMs = opts.commandTimeoutMs;
|
|
133
|
+
this.probeIntervalMs = opts.probeIntervalMs;
|
|
134
|
+
this.unhealthyRetryMs = opts.unhealthyRetryMs;
|
|
135
|
+
this.reconcileIntervalMs = opts.reconcileIntervalMs;
|
|
136
|
+
this.ttlSec = Math.round(opts.ttlMs / 1000);
|
|
137
|
+
this.transport = opts.transport;
|
|
138
|
+
this.now = opts.now ?? Date.now;
|
|
139
|
+
this.clientFactory = () =>
|
|
140
|
+
new RedisClient({
|
|
141
|
+
host: this.host,
|
|
142
|
+
port: this.port,
|
|
143
|
+
connectTimeoutMs: this.connectTimeoutMs,
|
|
144
|
+
commandTimeoutMs: this.commandTimeoutMs,
|
|
145
|
+
transport: this.transport,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 只读暴露。 */
|
|
150
|
+
get state(): RedisState {
|
|
151
|
+
return this._state;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── 写镜像(全部 fire-and-forget:同步返回、绝不 throw、绝不重试)────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 秒级事件级镜像:`HINCRBY opsx:usage:sec:<secNow> <provider> <delta>` +
|
|
158
|
+
* `EXPIRE`(TTL 续期)。healthy 才发;非 healthy 静默 no-op。跨 session 的
|
|
159
|
+
* 累加发生在写入侧——同秒所有 session 的 delta 汇入同一 field。
|
|
160
|
+
*/
|
|
161
|
+
onDelta(sessionId: string, provider: string, secNow: string, deltaTotal: number): void {
|
|
162
|
+
if (this._state !== 'healthy') return;
|
|
163
|
+
void sessionId; // sink 契约兼容:field 无需 session 维度(全局聚合)
|
|
164
|
+
if (provider.includes(':')) {
|
|
165
|
+
this.mirrorFailed = true; // 前置假设被破坏:跳过该次镜像
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const key = `opsx:usage:sec:${secNow}`;
|
|
169
|
+
this.fireAndForget(
|
|
170
|
+
this.getClient().pipeline([
|
|
171
|
+
['HINCRBY', key, provider, String(deltaTotal)],
|
|
172
|
+
['EXPIRE', key, String(this.ttlSec)],
|
|
173
|
+
]),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 累计级镜像:`HINCRBY opsx:usage:totals <provider>:total|input|output`(无 TTL,
|
|
179
|
+
* totals 永久累计)。delta 由 poller 按「当前累计 - 上次已写入」算出,失败不
|
|
180
|
+
* 重试(超时重试会双计),丢失由 reconcile 的文件权威覆盖修复。
|
|
181
|
+
*/
|
|
182
|
+
onTotalsDelta(provider: string, dTotal: number, dInput: number, dOutput: number): void {
|
|
183
|
+
if (this._state !== 'healthy') return;
|
|
184
|
+
if (provider.includes(':')) {
|
|
185
|
+
this.mirrorFailed = true;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.fireAndForget(
|
|
189
|
+
this.getClient().pipeline([
|
|
190
|
+
['HINCRBY', 'opsx:usage:totals', `${provider}:total`, String(dTotal)],
|
|
191
|
+
['HINCRBY', 'opsx:usage:totals', `${provider}:input`, String(dInput)],
|
|
192
|
+
['HINCRBY', 'opsx:usage:totals', `${provider}:output`, String(dOutput)],
|
|
193
|
+
]),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── tick 步骤(poller 每 1s 调用;绝对时间门控;自吞异常翻 unhealthy)──
|
|
198
|
+
|
|
199
|
+
/** 镜像写失败观察:`mirrorFailed` → 翻 `unhealthy` 并清标志(翻转延迟 ≤1s)。 */
|
|
200
|
+
observeMirrorFailure(_now: number): void {
|
|
201
|
+
if (this.mirrorFailed) {
|
|
202
|
+
this.mirrorFailed = false;
|
|
203
|
+
this._state = 'unhealthy';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 探测:`unknown` 立即 / `healthy` 每 `probeIntervalMs` / `unhealthy` 每
|
|
209
|
+
* `unhealthyRetryMs`(绝对时间门控,与 tick 次数无关)。每次尝试都更新
|
|
210
|
+
* `lastProbeAt`。PING 成功 → `healthy`(此前非 healthy 则 `lastReconcileAt=0`
|
|
211
|
+
* 触发下一次 tick 立即 reconcile);失败 → close 失效 client + `unhealthy`。
|
|
212
|
+
* 绝不向外抛。
|
|
213
|
+
*/
|
|
214
|
+
async maybeProbe(now: number): Promise<void> {
|
|
215
|
+
let due: boolean;
|
|
216
|
+
if (this._state === 'unknown') {
|
|
217
|
+
due = true;
|
|
218
|
+
} else if (this._state === 'healthy') {
|
|
219
|
+
due = now - this.lastProbeAt >= this.probeIntervalMs;
|
|
220
|
+
} else {
|
|
221
|
+
due = now - this.lastProbeAt >= this.unhealthyRetryMs;
|
|
222
|
+
}
|
|
223
|
+
if (!due) return;
|
|
224
|
+
this.lastProbeAt = now;
|
|
225
|
+
const wasHealthy = this._state === 'healthy';
|
|
226
|
+
try {
|
|
227
|
+
await this.getClient().ping();
|
|
228
|
+
if (!wasHealthy) this.lastReconcileAt = 0; // 恢复:立即 reconcile 补齐降级期增量
|
|
229
|
+
this._state = 'healthy';
|
|
230
|
+
} catch {
|
|
231
|
+
this.closeClient();
|
|
232
|
+
this._state = 'unhealthy';
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 预取读:healthy 时 pipeline 一次 HGETALL 秒窗口(nowSec-60 .. nowSec-2,
|
|
238
|
+
* 2s 滞后)+ `opsx:usage:totals`;解析为可直接渲染的 `RedisSnapshot`。
|
|
239
|
+
* totals 在 Redis 缺失/为空时用文件权威值兜底(Redis 重启的 ≤30s 空窗)。
|
|
240
|
+
* 失败 → close + 翻 unhealthy 并 rethrow(poller catch 后回退文件)。
|
|
241
|
+
* 非 healthy 直接返回空快照。
|
|
242
|
+
*/
|
|
243
|
+
async prefetch(realtime: RealtimeLike, now: number): Promise<RedisSnapshot> {
|
|
244
|
+
const snapshot: RedisSnapshot = { totals: {}, perSecond: {} };
|
|
245
|
+
if (this._state !== 'healthy') return snapshot;
|
|
246
|
+
try {
|
|
247
|
+
const commands: Array<{ cmd: string[]; abssec?: string }> = [];
|
|
248
|
+
const nowSec = Math.floor(now / 1000);
|
|
249
|
+
for (let sec = nowSec - 60; sec <= nowSec - 2; sec++) {
|
|
250
|
+
commands.push({ cmd: ['HGETALL', `opsx:usage:sec:${sec}`], abssec: String(sec) });
|
|
251
|
+
}
|
|
252
|
+
commands.push({ cmd: ['HGETALL', 'opsx:usage:totals'] });
|
|
253
|
+
if (commands.length === 0) return snapshot;
|
|
254
|
+
const replies = await this.getClient().pipeline(commands.map((c) => c.cmd));
|
|
255
|
+
for (let i = 0; i < replies.length; i++) {
|
|
256
|
+
this.applyPrefetchReply(snapshot, commands[i], replies[i]);
|
|
257
|
+
}
|
|
258
|
+
this.fallbackTotalsFromFile(snapshot, realtime);
|
|
259
|
+
return snapshot;
|
|
260
|
+
} catch (err) {
|
|
261
|
+
this.closeClient();
|
|
262
|
+
this._state = 'unhealthy';
|
|
263
|
+
throw err;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* reconcile 逐 key 覆盖:`HSET opsx:usage:totals`(文件全量字段)+ 冻结秒
|
|
269
|
+
* `HSET opsx:usage:sec:<sec> <provider> <值>` + `EXPIRE`;只覆盖已冻结秒
|
|
270
|
+
* (`sec < floor(now/1000)`),跳过当前进行中秒(事件级 HINCRBY 已领先文件,
|
|
271
|
+
* 覆盖会回退)。HSET 覆盖幂等、非增量合并。成功后更新 `lastReconcileAt`;
|
|
272
|
+
* 命令失败 → close + 翻 unhealthy 并 rethrow(poller 侧 catch)。非 healthy
|
|
273
|
+
* 静默 no-op。
|
|
274
|
+
*/
|
|
275
|
+
async reconcile(realtime: RealtimeLike, now: number): Promise<void> {
|
|
276
|
+
if (this._state !== 'healthy') return;
|
|
277
|
+
try {
|
|
278
|
+
const commands: string[][] = [];
|
|
279
|
+
const nowSec = Math.floor(now / 1000);
|
|
280
|
+
const totalsArgs: string[] = ['HSET', 'opsx:usage:totals'];
|
|
281
|
+
for (const [provider, bucket] of Object.entries(realtime.totals ?? {})) {
|
|
282
|
+
if (provider.includes(':')) continue;
|
|
283
|
+
totalsArgs.push(
|
|
284
|
+
`${provider}:total`,
|
|
285
|
+
String(bucket.total),
|
|
286
|
+
`${provider}:input`,
|
|
287
|
+
String(bucket.input),
|
|
288
|
+
`${provider}:output`,
|
|
289
|
+
String(bucket.output),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
if (totalsArgs.length > 2) commands.push(totalsArgs);
|
|
293
|
+
for (const [secStr, secMap] of Object.entries(realtime.seconds ?? {})) {
|
|
294
|
+
const sec = Number(secStr);
|
|
295
|
+
if (!Number.isInteger(sec) || sec >= nowSec) continue; // 只覆盖已冻结秒
|
|
296
|
+
const key = `opsx:usage:sec:${sec}`;
|
|
297
|
+
const args: string[] = ['HSET', key];
|
|
298
|
+
for (const [provider, amount] of Object.entries(secMap)) {
|
|
299
|
+
if (provider.includes(':')) continue;
|
|
300
|
+
args.push(provider, String(amount));
|
|
301
|
+
}
|
|
302
|
+
if (args.length > 2) {
|
|
303
|
+
commands.push(args);
|
|
304
|
+
commands.push(['EXPIRE', key, String(this.ttlSec)]);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (commands.length > 0) {
|
|
308
|
+
await this.getClient().pipeline(commands);
|
|
309
|
+
}
|
|
310
|
+
this.lastReconcileAt = now;
|
|
311
|
+
} catch (err) {
|
|
312
|
+
this.closeClient();
|
|
313
|
+
this._state = 'unhealthy';
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 释放连接;之后 fire-and-forget/await 路径经懒建重建。 */
|
|
319
|
+
close(): void {
|
|
320
|
+
this.closeClient();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── 内部 ─────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
private fireAndForget(p: Promise<unknown>): void {
|
|
326
|
+
void p.catch(() => {
|
|
327
|
+
this.mirrorFailed = true;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private getClient(): RedisClient {
|
|
332
|
+
if (this.client === null) {
|
|
333
|
+
this.client = this.clientFactory();
|
|
334
|
+
}
|
|
335
|
+
return this.client;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private closeClient(): void {
|
|
339
|
+
if (this.client !== null) {
|
|
340
|
+
try {
|
|
341
|
+
this.client.close();
|
|
342
|
+
} catch {
|
|
343
|
+
/* ignore */
|
|
344
|
+
}
|
|
345
|
+
this.client = null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 把一条 pipeline 回复(sec hash 或 totals hash)并入快照;异常 field 防御跳过。 */
|
|
350
|
+
private applyPrefetchReply(
|
|
351
|
+
snapshot: RedisSnapshot,
|
|
352
|
+
entry: { cmd: string[]; abssec?: string },
|
|
353
|
+
reply: RedisReply,
|
|
354
|
+
): void {
|
|
355
|
+
if (reply === null || !Array.isArray(reply)) return;
|
|
356
|
+
const fields = reply as string[];
|
|
357
|
+
for (let i = 0; i + 1 < fields.length; i += 2) {
|
|
358
|
+
const field = fields[i];
|
|
359
|
+
const value = fields[i + 1];
|
|
360
|
+
if (typeof field !== 'string' || typeof value !== 'string') continue;
|
|
361
|
+
if (entry.abssec !== undefined) {
|
|
362
|
+
// sec hash: field = provider, value = global tokens
|
|
363
|
+
if (!field || field.includes(':')) continue;
|
|
364
|
+
const amount = Number(value);
|
|
365
|
+
if (!Number.isFinite(amount)) continue;
|
|
366
|
+
const bySec = (snapshot.perSecond[field] ??= {});
|
|
367
|
+
bySec[entry.abssec] = (bySec[entry.abssec] ?? 0) + amount;
|
|
368
|
+
} else {
|
|
369
|
+
// totals hash: field = <provider>:metric
|
|
370
|
+
const parsed = parseTotalsField(field, value);
|
|
371
|
+
if (!parsed) continue;
|
|
372
|
+
const totals = (snapshot.totals[parsed.provider] ??= { total: 0, input: 0, output: 0 });
|
|
373
|
+
totals[parsed.metric] += parsed.num;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Redis totals 缺失/为空(如重启后)→ 用文件权威值兜底,展示零缺口。 */
|
|
379
|
+
private fallbackTotalsFromFile(snapshot: RedisSnapshot, realtime: RealtimeLike): void {
|
|
380
|
+
for (const [provider, bucket] of Object.entries(realtime.totals ?? {})) {
|
|
381
|
+
if (snapshot.totals[provider]) continue; // Redis 侧有(活数据)→ 以 Redis 为准
|
|
382
|
+
snapshot.totals[provider] = {
|
|
383
|
+
total: bucket.total,
|
|
384
|
+
input: bucket.input,
|
|
385
|
+
output: bucket.output,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|