@arcaneorion/dsh-model-channel-manager 0.3.0 → 0.3.8
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 +71 -6
- package/package.json +10 -2
- package/src/client.js +280 -86
- package/src/health-store.js +169 -0
- package/src/index.js +441 -98
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 健康数据存储:原始流水(事件/测速)搬入 storageDomain 的 per-record KV 表。
|
|
3
|
+
*
|
|
4
|
+
* 动机(审计 F11/F12/F23 的根因):0.1 时代健康流水整字段存在 settings 命名空间里,
|
|
5
|
+
* 与配置共用同一条「HMR 独占事务 + 整字段落盘」通道,导致:
|
|
6
|
+
* - 2s 防抖 flush 与 volatile-update 快照覆盖互相踩(刚记的账被旧快照抹掉);
|
|
7
|
+
* - profile patch 文件膨胀(实测 6350 行中 health 约占 3000 行);
|
|
8
|
+
* - 任何无关 settings 写入都会整段重写健康数据。
|
|
9
|
+
*
|
|
10
|
+
* 现在的归属:事实数据(append-only 事件)进 domain;settings 里只保留
|
|
11
|
+
* 一份给 client 渲染用的小投影(聚合摘要 + 运行态指针),由 host 定期刷新。
|
|
12
|
+
*
|
|
13
|
+
* 组合方式照抄 dsh-session-projection-cache 范本:
|
|
14
|
+
* - 依赖方 inject ['storageDomain'](dsh-base 已装 json 后端,root=storages/);
|
|
15
|
+
* - Service.init 里 open(spec),effect 持有 close;
|
|
16
|
+
* - per-record 布局:一条事件一个文档,坏记录 backup-and-skip 不阻塞整体。
|
|
17
|
+
*/
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
|
|
20
|
+
|
|
21
|
+
const WINDOW_MS = 7 * 24 * 3600 * 1000;
|
|
22
|
+
// 每渠道保留上限:30m/24h 视图 + selector 置顶绰绰有余,同时控制 storages/ 目录体量
|
|
23
|
+
const PER_KEY_LIMIT = 300;
|
|
24
|
+
|
|
25
|
+
/** 单条健康事件记录(zod,domain 落盘边界校验)。向后兼容:token 字段可选。 */
|
|
26
|
+
const eventRecord = z.object({
|
|
27
|
+
ts: z.number().int().nonnegative(),
|
|
28
|
+
provider: z.string(),
|
|
29
|
+
model: z.string(),
|
|
30
|
+
ok: z.boolean(),
|
|
31
|
+
ttftMs: z.number().nullable().optional(),
|
|
32
|
+
latencyMs: z.number().nullable().optional(),
|
|
33
|
+
code: z.string().nullable().optional(),
|
|
34
|
+
inputTokens: z.number().optional(),
|
|
35
|
+
outputTokens: z.number().optional(),
|
|
36
|
+
cacheReadTokens: z.number().optional(),
|
|
37
|
+
cacheWriteTokens: z.number().optional(),
|
|
38
|
+
reasoningTokens: z.number().optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** 单渠道的事件桶记录:key = provider(与旧 state.records 的键一致,迁移零转换)。 */
|
|
42
|
+
const bucketRecord = z.object({
|
|
43
|
+
events: z.array(eventRecord),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** 测速结果桶:key = 组 id(与旧 state.speedResults 的键一致)。 */
|
|
47
|
+
const speedRecord = z.object({
|
|
48
|
+
rows: z.array(z.object({
|
|
49
|
+
provider: z.string(),
|
|
50
|
+
model: z.string(),
|
|
51
|
+
ok: z.boolean(),
|
|
52
|
+
ttft: z.number().nullable(),
|
|
53
|
+
latency: z.number().nullable(),
|
|
54
|
+
at: z.number(),
|
|
55
|
+
failure: z.string().nullable().optional(),
|
|
56
|
+
})),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const healthDomainSpec = defineDomain({
|
|
60
|
+
name: 'model_channel_health',
|
|
61
|
+
version: 1,
|
|
62
|
+
layout: 'per-record',
|
|
63
|
+
invalidRecords: 'backup-and-skip',
|
|
64
|
+
tables: {
|
|
65
|
+
events: domainTable(bucketRecord),
|
|
66
|
+
speed: domainTable(speedRecord),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* HealthStore:封装 domain 读写 + 保留策略 + 聚合。
|
|
72
|
+
* 非 Service(宿主插件自有生命周期),由 apply() 创建并 effect 持有 close。
|
|
73
|
+
*/
|
|
74
|
+
export class HealthStore {
|
|
75
|
+
constructor(ctx) {
|
|
76
|
+
this.ctx = ctx;
|
|
77
|
+
this.ready = null; // Promise<Domain>
|
|
78
|
+
this.events = null; // KvTable
|
|
79
|
+
this.speed = null; // KvTable
|
|
80
|
+
this.eventChain = Promise.resolve(); // 类内写链:appendEvent 串行化(并发不丢更新)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 打开 domain(幂等)。dsh-base 未装 storage 栈的 profile 会 reject——调用方降级。 */
|
|
84
|
+
open() {
|
|
85
|
+
if (this.ready === null) {
|
|
86
|
+
this.ready = this.ctx.storageDomain.open(healthDomainSpec).then((domain) => {
|
|
87
|
+
this.events = domain.table('events');
|
|
88
|
+
this.speed = domain.table('speed');
|
|
89
|
+
return domain;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return this.ready;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** effect 用:domain 关闭(写入队列排干后释放)。 */
|
|
96
|
+
async close() {
|
|
97
|
+
if (this.ready === null) return;
|
|
98
|
+
const domain = await this.ready.catch(() => null);
|
|
99
|
+
this.ready = null;
|
|
100
|
+
this.events = null;
|
|
101
|
+
this.speed = null;
|
|
102
|
+
if (domain) await domain.close().catch(() => {});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 追加一条事件到渠道桶,同时执行窗口过期 + 条数截断。
|
|
106
|
+
* 并发安全:桶初始化(put)与追加(update)都排队到类内写链,逐个重读当前值,
|
|
107
|
+
* 并发 appendEvent 不交错、不互相覆盖(等价 KvTable.update 的原子语义;
|
|
108
|
+
* 桶不存在时 KvTable.update 报 missing-key,故初始化先行)。
|
|
109
|
+
*/
|
|
110
|
+
appendEvent(provider, rec) {
|
|
111
|
+
if (this.events === null) return Promise.resolve();
|
|
112
|
+
this.eventChain = this.eventChain.then(async () => {
|
|
113
|
+
const cutoff = Date.now() - WINDOW_MS;
|
|
114
|
+
const cur = this.events.get(provider);
|
|
115
|
+
if (cur === undefined) {
|
|
116
|
+
await this.events.put(provider, { events: [rec] });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await this.events.update(provider, (c) => ({
|
|
120
|
+
events: [...c.events.filter((e) => e.ts >= cutoff), rec].slice(-PER_KEY_LIMIT),
|
|
121
|
+
}));
|
|
122
|
+
}).catch(() => { }); // 链不断:失败吞掉(调用方已有 warn),后续追加继续
|
|
123
|
+
return this.eventChain;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 整组测速结果替换写入。 */
|
|
127
|
+
async putSpeedRows(groupId, rows) {
|
|
128
|
+
if (this.speed === null) return;
|
|
129
|
+
await this.speed.put(groupId, { rows });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 读全部事件桶(迁移/聚合用)。 */
|
|
133
|
+
allEventBuckets() {
|
|
134
|
+
const out = {};
|
|
135
|
+
if (this.events === null) return out;
|
|
136
|
+
for (const [provider, bucket] of this.events.entries()) out[provider] = bucket.events;
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 读全部测速桶。 */
|
|
141
|
+
allSpeedBuckets() {
|
|
142
|
+
const out = {};
|
|
143
|
+
if (this.speed === null) return out;
|
|
144
|
+
for (const [groupId, bucket] of this.speed.entries()) out[groupId] = bucket.rows;
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 存量迁移:settings health 子树的 records/speedResults 一次性搬入 domain。
|
|
150
|
+
* 迁移以「桶里有数据即跳过」防重放;完成后由调用方写迁移标记。
|
|
151
|
+
* 并发安全:逐桶判断改走原子链(迁移进行中 recordHealth 并发创建的新桶不会被旧数据覆盖)。
|
|
152
|
+
*/
|
|
153
|
+
async migrateFrom(records, speedResults) {
|
|
154
|
+
if (this.events === null) return false;
|
|
155
|
+
const cutoff = Date.now() - WINDOW_MS;
|
|
156
|
+
for (const [provider, list] of Object.entries(records || {})) {
|
|
157
|
+
if (!Array.isArray(list) || list.length === 0) continue;
|
|
158
|
+
if (this.events.get(provider) !== undefined) continue; // 已有桶:domain 是权威,不回搬
|
|
159
|
+
const events = list.filter((e) => e && e.ts >= cutoff).slice(-PER_KEY_LIMIT);
|
|
160
|
+
if (events.length > 0) await this.events.put(provider, { events });
|
|
161
|
+
}
|
|
162
|
+
for (const [groupId, rows] of Object.entries(speedResults || {})) {
|
|
163
|
+
if (!Array.isArray(rows) || rows.length === 0) continue;
|
|
164
|
+
if (this.speed.get(groupId) !== undefined) continue;
|
|
165
|
+
await this.speed.put(groupId, { rows });
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
}
|