@mzzsfy/dsh-usage-dash 0.1.0 → 0.2.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/src/collector.js CHANGED
@@ -9,11 +9,15 @@ const UNKNOWN_SESSION_ID = '(unknown-session)'
9
9
  const SEQ_UNKNOWN = -1
10
10
  const DEFAULT_BACKFILL_CONCURRENCY = 4
11
11
  const MARK_BATCH = 32
12
+ const SCAN_LOG_MAX_ENTRIES = 200
12
13
 
13
14
  // 会话内单 pass 折叠:跟踪每个 (turn,step) 槽的最新报告,只把首次发射交 store
14
15
  export class UsageFold {
15
16
  constructor() {
16
17
  this.seen = new Map()
18
+ // (turn,step) 最近一次模型启动时刻:retry-started 覆盖重置,时长与首样本
19
+ // token 同源配对(分子只含最终尝试,分母不含失败尝试,避免速度被污染)
20
+ this.starts = new Map()
17
21
  }
18
22
 
19
23
  keyOf(event) {
@@ -37,6 +41,8 @@ export class UsageFold {
37
41
  if (event.type === 'step/start' || event.type === 'llm/retry-started') {
38
42
  // step/start 恰开一次模型调用,retry-started 标记每次实际启动的重试;
39
43
  // 请求只由标记计数,与 token 样本双计
44
+ const key = this.keyOf(event)
45
+ if (key !== null) this.starts.set(key, event.time)
40
46
  return {
41
47
  time: event.time,
42
48
  inputTokens: 0,
@@ -69,6 +75,13 @@ export class UsageFold {
69
75
  cacheReadTokens: usage.cacheReadTokens ?? 0,
70
76
  cacheWriteTokens: usage.cacheWriteTokens ?? 0,
71
77
  }
78
+ // 模型时长口径与官方 session-stats 投影 llmMs 同构(step/start → 汇报时刻),
79
+ // 附加到交 store 的首样本;未观测起点或时刻倒挂(时钟回拨)不附,0 视为无
80
+ const start = key !== null ? this.starts.get(key) : undefined
81
+ if (start !== undefined) {
82
+ const durationMs = Math.max(0, event.time - start)
83
+ if (durationMs > 0) sample.durationMs = durationMs
84
+ }
72
85
  if (key === null) return sample
73
86
  const prev = this.seen.get(key)
74
87
  this.seen.set(key, sample)
@@ -93,6 +106,10 @@ export class UsageCollector {
93
106
  constructor(ctx, store) {
94
107
  this.ctx = ctx
95
108
  this.store = store
109
+ // store 写合并周期落盘的失败行接入扫描异常日志,面板可观测
110
+ store.onFlushError = (error) => {
111
+ this.pushLog('record', error instanceof Error ? error.message : String(error))
112
+ }
96
113
  this.folds = new Map()
97
114
  this.routes = new Map()
98
115
  this.started = false
@@ -108,16 +125,33 @@ export class UsageCollector {
108
125
  scannedSessions: 0,
109
126
  lastSessionId: undefined,
110
127
  error: undefined,
111
- recordFailures: 0,
112
- skippedSessions: 0,
128
+ log: [],
113
129
  }
114
- // error 保留给采集器自身故障;单会话读取失败走 skippedSessions 跳过计数
130
+ // error 保留给采集器自身故障;单会话读取失败走日志 skipped 条目,计数由日志派生
115
131
  }
116
132
 
117
133
  #state
118
134
 
119
135
  status() {
120
- return { ...this.#state }
136
+ const log = [...this.#state.log]
137
+ return {
138
+ running: this.#state.running,
139
+ total: this.#state.total,
140
+ done: this.#state.done,
141
+ scannedSessions: this.#state.scannedSessions,
142
+ lastSessionId: this.#state.lastSessionId,
143
+ error: this.#state.error,
144
+ skippedSessions: log.filter((entry) => entry.kind === 'skipped').length,
145
+ recordFailures: log.filter((entry) => entry.kind === 'record').length,
146
+ log,
147
+ }
148
+ }
149
+
150
+ // 扫描异常日志:供面板明细展示,超上限丢最旧
151
+ pushLog(kind, detail) {
152
+ const log = this.#state.log
153
+ log.push({ time: Date.now(), kind, detail })
154
+ if (log.length > SCAN_LOG_MAX_ENTRIES) log.splice(0, log.length - SCAN_LOG_MAX_ENTRIES)
121
155
  }
122
156
 
123
157
  get running() {
@@ -148,8 +182,8 @@ export class UsageCollector {
148
182
  if (!sample) return
149
183
  // turn 标记落合成行不归因;其余样本先取本调用 source,再取会话路由
150
184
  if (!sample.turn) sample.model = fromSource ?? this.routeFor(sid)
151
- void this.store.record(sample).catch(() => {
152
- this.#state.recordFailures += 1
185
+ void this.store.record(sample).catch((error) => {
186
+ this.pushLog('record', error?.message ?? String(error))
153
187
  })
154
188
  })
155
189
  // 释放销毁会话的内存桶,防长跑宿主按会话数累积
@@ -192,8 +226,8 @@ export class UsageCollector {
192
226
  const batch = [...this.liveMarkBuffer.entries()]
193
227
  this.liveMarkBuffer.clear()
194
228
  if (batch.length > 0) {
195
- void this.store.markLiveSequences(batch).catch(() => {
196
- this.#state.recordFailures += 1
229
+ void this.store.markLiveSequences(batch).catch((error) => {
230
+ this.pushLog('record', error?.message ?? String(error))
197
231
  })
198
232
  }
199
233
  })
@@ -252,7 +286,7 @@ export class UsageCollector {
252
286
  const targets = headers.filter((header) => !seen.has(header.id))
253
287
  this.#state.total = targets.length
254
288
  this.#state.done = 0
255
- this.#state.skippedSessions = 0
289
+ this.#state.log = []
256
290
  const workerCount = Math.min(DEFAULT_BACKFILL_CONCURRENCY, Math.max(1, targets.length))
257
291
  let next = 0
258
292
  const completed = []
@@ -310,9 +344,9 @@ export class UsageCollector {
310
344
  completed.push(header.id)
311
345
  if (completed.length >= MARK_BATCH) await flushCompleted()
312
346
  this.#state.scannedSessions += 1
313
- } catch {
314
- // 读取失败(如宿主报会话日志损坏)按定案跳过:计数并继续,不中断回扫、不挂错误横幅
315
- this.#state.skippedSessions += 1
347
+ } catch (error) {
348
+ // 读取失败(如宿主报会话日志损坏)按定案跳过:记日志并继续,不中断回扫、不挂错误横幅
349
+ this.pushLog('skipped', error?.message ? `${header.id} ${error.message}` : header.id)
316
350
  } finally {
317
351
  this.#state.done += 1
318
352
  }
package/src/index.js CHANGED
@@ -7,7 +7,7 @@ import schemastery from '@deepseek-ai/schemastery'
7
7
  import { UsageCollector } from './collector.js'
8
8
  import { CURRENCIES, CONDITION_KINDS, UNIT_PER_MILLION } from './pricing.js'
9
9
  import { registerUsageRoutes } from './routes.js'
10
- import { DEFAULT_MINUTE_RETENTION_DAYS, sharedStore } from './store.js'
10
+ import { DEFAULT_MINUTE_RETENTION_DAYS, MINUTE_RETENTION_MAX_DAYS, sharedStore } from './store.js'
11
11
  // 顶层 inject 仅声明 web profile 必然存在的四个服务;settings 在 apply 内
12
12
  // 嵌套 inject(通道级静默不激活),构成干净禁用。
13
13
  export const inject = ['webServer', 'sessionPersistence', 'sessions', 'storageDomain']
@@ -50,7 +50,7 @@ const PRICING_RULE_SCHEMA = schemastery.object({
50
50
 
51
51
  const SETTINGS_SCHEMA = schemastery.object({
52
52
  minuteRetentionDays: schemastery.number().min(0).step(1).default(DEFAULT_MINUTE_RETENTION_DAYS)
53
- .description('分钟桶保留天数(上限 48h),0 表示禁用分钟桶'),
53
+ .description(`分钟桶保留天数(上限 ${MINUTE_RETENTION_MAX_DAYS} 天),0 表示禁用分钟桶`),
54
54
  pricing: schemastery.object({ rules: schemastery.array(PRICING_RULE_SCHEMA) })
55
55
  .description('定价规则(wire 由 /api/usage-dash/pricing 读写)'),
56
56
  })
package/src/pricing.js CHANGED
@@ -7,6 +7,12 @@ export const CONDITION_KINDS = ['dailyWindow', 'weekdays', 'monthDays', 'dateRan
7
7
  export const TOKENS_PER_MILLION = 1000 * 1000
8
8
 
9
9
  const MODEL_WILDCARD = '*'
10
+ const SEGMENT_SEPARATOR = '/'
11
+ // 请求侧无斜杠时的 vendor 段缺省值,与存储行 provider 口径同源
12
+ export const PROVIDER_UNSET = 'default'
13
+ // 档位权重:vendor 段通配 1 档、model 段通配 2 档,和越小越优先(模型名精确档恒优于供应商精确档)
14
+ const VENDOR_WILDCARD_TIER = 1
15
+ const MODEL_WILDCARD_TIER = 2
10
16
  const MINUTES_PER_HOUR = 60
11
17
  const DAY_PART_WIDTH = 2
12
18
  const ISO_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
@@ -79,12 +85,31 @@ const isRuleShaped = (rule) =>
79
85
  && !!rule.price && typeof rule.price === 'object' && !Array.isArray(rule.price)
80
86
  && Array.isArray(rule.conditions)
81
87
 
82
- const firstMatchingPrice = (rules, date, modelFilter) => {
83
- for (const rule of rules) {
84
- if (!isRuleShaped(rule) || !modelFilter(rule)) continue
85
- if (rule.conditions.every((condition) => conditionMatches(condition, date))) return rule.price
86
- }
87
- return null
88
+ // 规则模型键必须两段式:首个 / 前 vendor 段、后模型段(允许含 /),首位斜杠或无斜杠均非法
89
+ export const splitRuleSegments = (pattern) => {
90
+ const slash = pattern.indexOf(SEGMENT_SEPARATOR)
91
+ return slash > 0 ? [pattern.slice(0, slash), pattern.slice(slash + SEGMENT_SEPARATOR.length)] : null
92
+ }
93
+
94
+ // 段须非空且不含空白:含空白的模型键永不匹配真实请求
95
+ const SEGMENT_PATTERN = /^\S+$/
96
+
97
+ export const isTwoSegmentModel = (pattern) => {
98
+ const segments = splitRuleSegments(pattern)
99
+ return segments !== null && segments.every((segment) => SEGMENT_PATTERN.test(segment))
100
+ }
101
+
102
+ // 请求侧模型键按同构规则分段,无 / 或首位斜杠时 vendor 段缺省归 default(与存储行口径一致)
103
+ export const splitRequestSegments = (model) => splitRuleSegments(model) ?? [PROVIDER_UNSET, model]
104
+
105
+ // 段级比对:规则段为通配或与请求段相等;两段全过才成立,通配段按各自档位计权
106
+ const matchTier = (ruleSegments, requestSegments) => {
107
+ const [ruleVendor, ruleModel] = ruleSegments
108
+ const [requestVendor, requestModel] = requestSegments
109
+ if (ruleVendor !== MODEL_WILDCARD && ruleVendor !== requestVendor) return null
110
+ if (ruleModel !== MODEL_WILDCARD && ruleModel !== requestModel) return null
111
+ return (ruleVendor === MODEL_WILDCARD ? VENDOR_WILDCARD_TIER : 0)
112
+ + (ruleModel === MODEL_WILDCARD ? MODEL_WILDCARD_TIER : 0)
88
113
  }
89
114
 
90
115
  const toLocalDate = (timestamp) => {
@@ -92,12 +117,25 @@ const toLocalDate = (timestamp) => {
92
117
  return Number.isNaN(date.getTime()) ? null : date
93
118
  }
94
119
 
95
- // 精确子集按数组序取首个命中;无精确子集或全不命中回落 '*' 子集;仍无命中为 null(调用方计 unpriced)
120
+ // 匹配链:全名 > 模型名(vendor 通配)> 供应商(model 通配)> '*/*' 全通;
121
+ // 档位最小者胜,同档按数组序取首个;高档条件不满足自然落低档;无命中为 null(调用方计 unpriced)
96
122
  export const matchPrice = (rules, model, timestamp) => {
97
123
  const date = toLocalDate(timestamp)
98
124
  if (!Array.isArray(rules) || !date || typeof model !== 'string') return null
99
- return firstMatchingPrice(rules, date, (rule) => rule.model === model)
100
- ?? firstMatchingPrice(rules, date, (rule) => rule.model === MODEL_WILDCARD)
125
+ const requestSegments = splitRequestSegments(model)
126
+ let bestTier = Infinity
127
+ let bestPrice = null
128
+ for (const rule of rules) {
129
+ if (!isRuleShaped(rule)) continue
130
+ const ruleSegments = splitRuleSegments(rule.model)
131
+ if (!ruleSegments) continue
132
+ const tier = matchTier(ruleSegments, requestSegments)
133
+ if (tier === null || tier >= bestTier) continue
134
+ if (!rule.conditions.every((condition) => conditionMatches(condition, date))) continue
135
+ bestTier = tier
136
+ bestPrice = rule.price
137
+ }
138
+ return bestPrice
101
139
  }
102
140
 
103
141
  const BUCKET_PRICE_KEYS = [
package/src/query.js CHANGED
@@ -7,6 +7,7 @@ export const MAX_SLOTS = 2000
7
7
 
8
8
  const PAD_WIDTH = 2
9
9
  const PERCENT_SCALE = 100
10
+ const MS_PER_SECOND = 1000
10
11
 
11
12
  const GRANULARITY_DAILY = 'D'
12
13
  const GRANULARITY_HOURLY = 'H'
@@ -115,8 +116,18 @@ export function aggregateRange(rows, g, from, to) {
115
116
  slot.byModel[row.model] = (slot.byModel[row.model] ?? 0) + tokens
116
117
  slot.byProvider[row.provider] = (slot.byProvider[row.provider] ?? 0) + tokens
117
118
  const modelTotal = modelTotals.get(row.model)
118
- if (modelTotal) modelTotal.tokens += tokens
119
- else modelTotals.set(row.model, { provider: row.provider, tokens })
119
+ if (modelTotal) {
120
+ modelTotal.tokens += tokens
121
+ modelTotal.speedDurationMs += row.durationMs ?? 0
122
+ modelTotal.speedOutputTokens += row.durationMs ? row.outputTokens : 0
123
+ } else {
124
+ modelTotals.set(row.model, {
125
+ provider: row.provider,
126
+ tokens,
127
+ speedDurationMs: row.durationMs ?? 0,
128
+ speedOutputTokens: row.durationMs ? row.outputTokens : 0,
129
+ })
130
+ }
120
131
  providerTotals.set(row.provider, (providerTotals.get(row.provider) ?? 0) + tokens)
121
132
  }
122
133
  const totals = { tokens: 0, requests: 0, turns: 0, cacheHit: 0, cacheMiss: 0 }
@@ -127,8 +138,16 @@ export function aggregateRange(rows, g, from, to) {
127
138
  totals.cacheHit += slot.cacheHit
128
139
  totals.cacheMiss += slot.cacheMiss
129
140
  }
141
+ // speed = 配对口径的输出 token ÷ 模型时长秒;仅时长>0 的行计入分子分母,
142
+ // 存量旧格式行只进 tokens 不进分母,无时长数据条目不挂 speed 字段
130
143
  const models = [...modelTotals.entries()]
131
- .map(([model, agg]) => ({ model, provider: agg.provider, tokens: agg.tokens, percent: percentOf(agg.tokens, totals.tokens) }))
144
+ .map(([model, agg]) => ({
145
+ model,
146
+ provider: agg.provider,
147
+ tokens: agg.tokens,
148
+ percent: percentOf(agg.tokens, totals.tokens),
149
+ ...(agg.speedDurationMs > 0 ? { speed: agg.speedOutputTokens / (agg.speedDurationMs / MS_PER_SECOND) } : {}),
150
+ }))
132
151
  .sort((a, b) => b.tokens - a.tokens)
133
152
  const providers = [...providerTotals.entries()]
134
153
  .map(([provider, tokens]) => ({ provider, tokens, percent: percentOf(tokens, totals.tokens) }))
package/src/routes.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import { z } from 'zod'
8
8
 
9
9
  import { aggregateRange, attachCosts } from './query.js'
10
- import { CURRENCIES, CONDITION_KINDS, UNIT_PER_MILLION } from './pricing.js'
10
+ import { CURRENCIES, CONDITION_KINDS, UNIT_PER_MILLION, isTwoSegmentModel } from './pricing.js'
11
11
  import {
12
12
  GRANULARITY_DAILY,
13
13
  GRANULARITY_HOURLY,
@@ -75,7 +75,8 @@ const conditionSchema = z.union(
75
75
 
76
76
  const pricingRulesSchema = z.array(
77
77
  z.object({
78
- model: z.string(),
78
+ // 模型键强制两段式 vendor/model(段可通配),单段旧形态提交即拒
79
+ model: z.string().refine(isTwoSegmentModel),
79
80
  unit: z.literal(UNIT_PER_MILLION).optional(),
80
81
  currency: z.enum(CURRENCIES).nullable(),
81
82
  price: z.object({
package/src/store.js CHANGED
@@ -1,28 +1,33 @@
1
1
  // 用量统计存储:usage_stats 域,单表 buckets,行键 <粒度>|<桶串>|<provider>|<model>。
2
- // 写入走 update 的原子读改写,缺键时 put 种子后重试一次;同一样本三粒度三行
3
- // 全部尝试后聚合上抛,单粒度失败不造成其余粒度缺失。游标读写全部串行在
4
- // 同一条 promise 链上,防 global 整值覆写的 lost update。进程级单例挂
5
- // globalThis,防 HMR 热重载后重复开域。
2
+ // single 布局每次持久化写都全量重发布 unit 文档,故写入侧做合并(write-behind):
3
+ // record 同步累加进内存 pending,按周期 flush 批量落盘,同桶多样本折叠为一次
4
+ // update;flush 失败的行留在 pending 下轮重试,错误经 onFlushError 上抛。
5
+ // 游标读写与 flush 全部串行在同一条 promise 链上,防 lost update。进程级单例
6
+ // 挂 globalThis,防 HMR 热重载后重复开域。
6
7
 
7
8
  import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
8
9
  import { z } from 'zod'
9
10
 
11
+ import { PROVIDER_UNSET, splitRequestSegments } from './pricing.js'
12
+
10
13
  export const GRANULARITY_DAILY = 'D'
11
14
  export const GRANULARITY_HOURLY = 'H'
12
15
  export const GRANULARITY_MINUTE = 'M'
13
16
 
14
- export const PROVIDER_DEFAULT = 'default'
15
17
  export const MODEL_TURNS = '(turns)'
16
18
  export const MODEL_UNKNOWN = '(unknown)'
17
19
 
18
- export const DEFAULT_MINUTE_RETENTION_DAYS = 2
20
+ export const DEFAULT_MINUTE_RETENTION_DAYS = 7
19
21
 
20
22
  // 分钟桶对齐粒度(分钟);小时/天桶不受影响
21
23
  export const MINUTE_BUCKET_SPAN_MINUTES = 10
22
24
 
23
- // 保留上限:小时桶固定 15 天,分钟桶可配置但最大 2 天(48h)
25
+ // 保留上限:小时桶固定 15 天,分钟桶可配置但最大 7 天(与分钟视图选择上限一致)
24
26
  export const HOUR_RETENTION_DAYS = 15
25
- export const MINUTE_RETENTION_MAX_DAYS = 2
27
+ export const MINUTE_RETENTION_MAX_DAYS = 7
28
+
29
+ // 写合并周期:pending 样本最长延迟该时长落盘;统计可由会话重扫重建,容忍窗口内丢失
30
+ export const FLUSH_INTERVAL_MS = 2000
26
31
 
27
32
  const DAY_MS = 24 * 60 * 60 * 1000
28
33
  const PAD_WIDTH = 2
@@ -64,9 +69,9 @@ export const GRANULARITIES = [
64
69
  [GRANULARITY_MINUTE, minuteKey],
65
70
  ]
66
71
 
72
+ // vendor 段推导与定价匹配同源:首个 / 前段,无 / 或首位斜杠归 default
67
73
  export function providerOf(modelRef) {
68
- const slash = modelRef.indexOf('/')
69
- return slash > 0 ? modelRef.slice(0, slash) : PROVIDER_DEFAULT
74
+ return splitRequestSegments(modelRef)[0]
70
75
  }
71
76
 
72
77
  export const usageRowSchema = z.object({
@@ -77,6 +82,8 @@ export const usageRowSchema = z.object({
77
82
  outputTokens: z.number(),
78
83
  cacheReadTokens: z.number(),
79
84
  cacheWriteTokens: z.number(),
85
+ // 模型时长累计(毫秒):optional 兼容存量记录(域 open 逐记录 parse)
86
+ durationMs: z.number().optional(),
80
87
  requests: z.number(),
81
88
  turns: z.number(),
82
89
  lastSeen: z.number(),
@@ -110,12 +117,47 @@ function emptyRow(bucket, provider, model, nowMs) {
110
117
  outputTokens: 0,
111
118
  cacheReadTokens: 0,
112
119
  cacheWriteTokens: 0,
120
+ durationMs: 0,
113
121
  requests: 0,
114
122
  turns: 0,
115
123
  lastSeen: nowMs,
116
124
  }
117
125
  }
118
126
 
127
+ // 样本折叠为计数增量:turn/request 只计次,token 样本累加四类桶
128
+ function deltaOf(sample, nowMs) {
129
+ const delta = emptyRow('', '', '', nowMs)
130
+ delete delta.bucket
131
+ delete delta.provider
132
+ delete delta.model
133
+ if (sample.turn) delta.turns = 1
134
+ else if (sample.request) delta.requests = 1
135
+ else {
136
+ delta.inputTokens = sample.inputTokens
137
+ delta.outputTokens = sample.outputTokens
138
+ delta.cacheReadTokens = sample.cacheReadTokens
139
+ delta.cacheWriteTokens = sample.cacheWriteTokens
140
+ delta.durationMs = sample.durationMs ?? 0
141
+ }
142
+ return delta
143
+ }
144
+
145
+ // 增量累加:行与 pending 条目同构,恒等字段取 base,计数逐项相加,时刻取较新
146
+ function addDelta(base, delta) {
147
+ return {
148
+ ...base,
149
+ inputTokens: base.inputTokens + delta.inputTokens,
150
+ outputTokens: base.outputTokens + delta.outputTokens,
151
+ cacheReadTokens: base.cacheReadTokens + delta.cacheReadTokens,
152
+ cacheWriteTokens: base.cacheWriteTokens + delta.cacheWriteTokens,
153
+ // base 侧 ?? 0 容存量旧格式行(缺字段);delta 侧经 deltaOf 恒为数值
154
+ durationMs: (base.durationMs ?? 0) + delta.durationMs,
155
+ requests: base.requests + delta.requests,
156
+ turns: base.turns + delta.turns,
157
+ lastSeen: Math.max(base.lastSeen, delta.lastSeen),
158
+ }
159
+ }
160
+
119
161
  function isMissingRecord(err) {
120
162
  return err instanceof Error && MISSING_RECORD_PATTERN.test(err.message)
121
163
  }
@@ -124,11 +166,16 @@ export class UsageStore {
124
166
  constructor(facility, options = {}) {
125
167
  this.now = options.now ?? Date.now
126
168
  this.retentionDays = options.retentionDays ?? (() => DEFAULT_MINUTE_RETENTION_DAYS)
169
+ this.flushIntervalMs = options.flushIntervalMs ?? FLUSH_INTERVAL_MS
170
+ // flush 行级失败上抛缝:采集器注入后接入扫描异常日志
171
+ this.onFlushError = options.onFlushError
127
172
  this.table = null
128
173
  this.domain = null
129
174
  this.openError = undefined
130
175
  this.lastPruneDay = ''
131
176
  this.markChain = Promise.resolve()
177
+ this.pending = new Map()
178
+ this.flushTimer = undefined
132
179
  // ready 永远 resolve:打开失败转降级,操作按调用失败,逃逸拒绝会拖垮宿主
133
180
  this.ready = this.initialize(facility).catch((err) => {
134
181
  this.openError = err
@@ -173,16 +220,73 @@ export class UsageStore {
173
220
  return this.readCursor() ?? {}
174
221
  }
175
222
 
223
+ // 同步合并进 pending,不等待持久化;崩溃丢失窗口 = flush 周期
176
224
  async record(sample) {
177
225
  await this.ready
178
- const table = this.requireTable()
179
- await this.pruneOncePerDay()
226
+ this.requireTable()
180
227
  const nowMs = this.now()
181
- const outcomes = await Promise.allSettled(
182
- GRANULARITIES.map(([g, bucketOf]) => this.recordRow(table, g, bucketOf(sample.time), sample, nowMs)),
183
- )
184
- const failures = outcomes.flatMap((outcome) => (outcome.status === 'rejected' ? [outcome.reason] : []))
185
- if (failures.length > 0) throw new AggregateError(failures, 'usage store record failed')
228
+ const model = sample.turn ? MODEL_TURNS : sample.model ? sample.model : MODEL_UNKNOWN
229
+ const provider = sample.turn ? PROVIDER_UNSET : providerOf(model)
230
+ const delta = deltaOf(sample, nowMs)
231
+ for (const [g, bucketOf] of GRANULARITIES) {
232
+ const bucket = bucketOf(sample.time)
233
+ const entry = { ...delta, bucket, provider, model }
234
+ const existing = this.pending.get(rowKey(g, bucket, provider, model))
235
+ this.pending.set(rowKey(g, bucket, provider, model), existing ? addDelta(existing, entry) : entry)
236
+ }
237
+ this.scheduleFlush()
238
+ }
239
+
240
+ scheduleFlush() {
241
+ if (this.flushTimer !== undefined) return
242
+ this.flushTimer = setTimeout(() => {
243
+ this.flushTimer = undefined
244
+ void this.flushNow()
245
+ }, this.flushIntervalMs)
246
+ }
247
+
248
+ // pending 批量落盘:每脏行一次原子读改写,失败的行并回 pending 下轮重试,
249
+ // 错误聚合后经 onFlushError 上抛;与游标写同链串行
250
+ flushNow() {
251
+ const run = this.markChain.then(async () => {
252
+ await this.ready
253
+ if (this.pending.size === 0) return
254
+ try {
255
+ await this.flushBatch()
256
+ } catch (err) {
257
+ this.onFlushError?.(err)
258
+ }
259
+ })
260
+ this.markChain = run.then(() => {}, () => {})
261
+ return run
262
+ }
263
+
264
+ async flushBatch() {
265
+ await this.pruneOncePerDay()
266
+ const table = this.requireTable()
267
+ const batch = this.pending
268
+ this.pending = new Map()
269
+ const outcomes = await Promise.allSettled([...batch].map(([key, delta]) => this.applyDelta(table, key, delta)))
270
+ const failures = outcomes.flatMap((outcome, index) => {
271
+ if (outcome.status !== 'rejected') return []
272
+ const key = [...batch.keys()][index]
273
+ const [entryKey, entry] = [key, batch.get(key)]
274
+ const existing = this.pending.get(entryKey)
275
+ this.pending.set(entryKey, existing ? addDelta(existing, entry) : entry)
276
+ return [outcome.reason]
277
+ })
278
+ if (failures.length > 0) throw new AggregateError(failures, 'usage store flush failed')
279
+ }
280
+
281
+ async applyDelta(table, key, delta) {
282
+ const apply = (current) => addDelta(current ?? emptyRow(delta.bucket, delta.provider, delta.model, delta.lastSeen), delta)
283
+ try {
284
+ await table.update(key, apply)
285
+ } catch (err) {
286
+ if (!isMissingRecord(err)) throw err
287
+ await table.put(key, emptyRow(delta.bucket, delta.provider, delta.model, delta.lastSeen))
288
+ await table.update(key, apply)
289
+ }
186
290
  }
187
291
 
188
292
  async pruneOncePerDay() {
@@ -220,33 +324,8 @@ export class UsageStore {
220
324
  }
221
325
  }
222
326
 
223
- async recordRow(table, g, bucket, sample, nowMs) {
224
- const model = sample.turn ? MODEL_TURNS : sample.model ? sample.model : MODEL_UNKNOWN
225
- const provider = sample.turn ? PROVIDER_DEFAULT : providerOf(model)
226
- const key = rowKey(g, bucket, provider, model)
227
- const apply = (current) => {
228
- const base = current ?? emptyRow(bucket, provider, model, nowMs)
229
- if (sample.turn) return { ...base, turns: base.turns + 1, lastSeen: nowMs }
230
- if (sample.request) return { ...base, requests: base.requests + 1, lastSeen: nowMs }
231
- return {
232
- ...base,
233
- inputTokens: base.inputTokens + sample.inputTokens,
234
- outputTokens: base.outputTokens + sample.outputTokens,
235
- cacheReadTokens: base.cacheReadTokens + sample.cacheReadTokens,
236
- cacheWriteTokens: base.cacheWriteTokens + sample.cacheWriteTokens,
237
- lastSeen: nowMs,
238
- }
239
- }
240
- try {
241
- await table.update(key, apply)
242
- } catch (err) {
243
- if (!isMissingRecord(err)) throw err
244
- await table.put(key, emptyRow(bucket, provider, model, nowMs))
245
- await table.update(key, apply)
246
- }
247
- }
248
-
249
327
  async rangeRows(g, from, to) {
328
+ await this.flushNow()
250
329
  await this.ready
251
330
  const table = this.requireTable()
252
331
  const prefix = `${g}|`
@@ -295,6 +374,7 @@ export class UsageStore {
295
374
 
296
375
  reset(boundaries) {
297
376
  return this.enqueueGlobalWrite(async () => {
377
+ await this.flushBatch()
298
378
  const table = this.requireTable()
299
379
  for (const key of [...table.keys()]) await table.delete(key)
300
380
  const liveFirstSeq = {}
@@ -0,0 +1,23 @@
1
+ // client.js 书挡契约:首行 IIFE 开、末行 IIFE 闭;求值前剥壳得可整源求值的函数体。
2
+ // 供整源求值类测试统一取数,形态由 client-scope.test.mjs 守卫。
3
+ import { readFileSync } from 'node:fs'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { dirname, join } from 'node:path'
6
+
7
+ const IIFE_OPEN = '(() => {'
8
+ const IIFE_CLOSE = '})()'
9
+
10
+ export const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8')
11
+
12
+ const source = CLIENT_SOURCE.trimEnd()
13
+ if (!source.startsWith(IIFE_OPEN) || !source.endsWith(IIFE_CLOSE)) {
14
+ throw new Error(`client.js 书挡契约破坏:应以 ${IIFE_OPEN} 开、${IIFE_CLOSE} 闭`)
15
+ }
16
+
17
+ export const CLIENT_BODY = source.slice(IIFE_OPEN.length, -IIFE_CLOSE.length).trim()
18
+
19
+ export const DECLARATION_NAMES = [
20
+ ...new Set(
21
+ [...CLIENT_BODY.matchAll(/^(?:const|function|let) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
22
+ ),
23
+ ]
@@ -0,0 +1,60 @@
1
+ // client.js 求值形态守卫:宿主以经典 script 整源求值,顶层词法声明落页面全局词法环境,
2
+ // 跨 bundle 同名即整脚本 SyntaxError 拒载;整文件 IIFE 书挡保证顶层零词法声明。
3
+ // Given/When/Then 场景内嵌于用例描述;vm.runInContext 与浏览器经典 script 同语义(全局词法环境跨脚本共享)。
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'node:fs'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { dirname, join } from 'node:path'
9
+ import vm from 'node:vm'
10
+
11
+ const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8').trimEnd()
12
+ const DECLARATION_NAMES = [
13
+ ...new Set(
14
+ [...CLIENT_SOURCE.matchAll(/^(?:const|let|var|class|(?:async )?function\*?) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
15
+ ),
16
+ ]
17
+ const BUNDLE_ID = '@mzzsfy/dsh-usage-dash'
18
+
19
+ test('Given 同 context 已有外部 CHART_PAD 声明(宿主经典 script 全局词法环境), When 整源求值 client.js, Then 双源共存不拒载且外部声明原值不变', () => {
20
+ const ctx = vm.createContext({})
21
+ vm.runInContext('const CHART_PAD = 1', ctx)
22
+ vm.runInContext(CLIENT_SOURCE, ctx)
23
+ assert.equal(vm.runInContext('CHART_PAD', ctx), 1)
24
+ })
25
+
26
+ test('Given client.js 已整源求值, When 同 context 再声明外部 CHART_PAD, Then 双源共存不拒载', () => {
27
+ const ctx = vm.createContext({})
28
+ vm.runInContext(CLIENT_SOURCE, ctx)
29
+ vm.runInContext('const CHART_PAD = 1', ctx)
30
+ })
31
+
32
+ test('Given client.js 整源求值完成, When 逐名以 const 重声明探针行首声明名, Then 全局词法环境零泄漏', () => {
33
+ const ctx = vm.createContext({})
34
+ vm.runInContext(CLIENT_SOURCE, ctx)
35
+ const leaked = DECLARATION_NAMES.filter((name) => {
36
+ try {
37
+ vm.runInContext(`const ${name} = null`, ctx)
38
+ return false
39
+ } catch {
40
+ return true
41
+ }
42
+ })
43
+ assert.deepEqual(leaked, [])
44
+ })
45
+
46
+ test('Given 宿主 loader 就绪, When 整源求值 client.js, Then 自注册照常发生且 id 不变', () => {
47
+ const registrations = []
48
+ const ctx = vm.createContext({ window: { __ModuleLoader__: { load: (registration) => registrations.push(registration) } } })
49
+ vm.runInContext(CLIENT_SOURCE, ctx)
50
+ assert.equal(registrations.length, 1)
51
+ assert.equal(registrations[0].id, BUNDLE_ID)
52
+ assert.equal(typeof registrations[0].factory, 'function')
53
+ })
54
+
55
+ test('Given 书挡契约, When 校验首末行, Then 首行 IIFE 开、末行 IIFE 闭且声明收集非空', () => {
56
+ const lines = CLIENT_SOURCE.split('\n')
57
+ assert.equal(lines[0], '(() => {')
58
+ assert.equal(lines[lines.length - 1], '})()')
59
+ assert.ok(DECLARATION_NAMES.length > 0)
60
+ })