@mzzsfy/dsh-usage-dash 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/src/store.js ADDED
@@ -0,0 +1,333 @@
1
+ // 用量统计存储:usage_stats 域,单表 buckets,行键 <粒度>|<桶串>|<provider>|<model>。
2
+ // 写入走 update 的原子读改写,缺键时 put 种子后重试一次;同一样本三粒度三行
3
+ // 全部尝试后聚合上抛,单粒度失败不造成其余粒度缺失。游标读写全部串行在
4
+ // 同一条 promise 链上,防 global 整值覆写的 lost update。进程级单例挂
5
+ // globalThis,防 HMR 热重载后重复开域。
6
+
7
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
8
+ import { z } from 'zod'
9
+
10
+ export const GRANULARITY_DAILY = 'D'
11
+ export const GRANULARITY_HOURLY = 'H'
12
+ export const GRANULARITY_MINUTE = 'M'
13
+
14
+ export const PROVIDER_DEFAULT = 'default'
15
+ export const MODEL_TURNS = '(turns)'
16
+ export const MODEL_UNKNOWN = '(unknown)'
17
+
18
+ export const DEFAULT_MINUTE_RETENTION_DAYS = 2
19
+
20
+ // 分钟桶对齐粒度(分钟);小时/天桶不受影响
21
+ export const MINUTE_BUCKET_SPAN_MINUTES = 10
22
+
23
+ // 保留上限:小时桶固定 15 天,分钟桶可配置但最大 2 天(48h)
24
+ export const HOUR_RETENTION_DAYS = 15
25
+ export const MINUTE_RETENTION_MAX_DAYS = 2
26
+
27
+ const DAY_MS = 24 * 60 * 60 * 1000
28
+ const PAD_WIDTH = 2
29
+ const DOMAIN_NAME = 'usage_stats'
30
+ const TABLE_BUCKETS = 'buckets'
31
+ const MISSING_RECORD_PATTERN = /no record .* to update/
32
+ const MINUTE_KEY_PREFIX = `${GRANULARITY_MINUTE}|`
33
+ const HOUR_KEY_PREFIX = `${GRANULARITY_HOURLY}|`
34
+
35
+ // 保留值归一:非法回落默认,超出上限截到上限;0(禁用)合法保留
36
+ export function clampMinuteRetentionDays(days) {
37
+ const raw = Number.isFinite(days) && days >= 0 ? days : DEFAULT_MINUTE_RETENTION_DAYS
38
+ return Math.min(raw, MINUTE_RETENTION_MAX_DAYS)
39
+ }
40
+
41
+ // 桶串统一本地时区推导,同粒度内字典序即时间序
42
+ const pad = (value) => String(value).padStart(PAD_WIDTH, '0')
43
+
44
+ export function dayKey(ts) {
45
+ const d = new Date(ts)
46
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
47
+ }
48
+
49
+ export function hourKey(ts) {
50
+ return `${dayKey(ts)}T${pad(new Date(ts).getHours())}`
51
+ }
52
+
53
+ // 分钟桶起点对齐 10 分钟:向下取整到桶边界,不跨时
54
+ export function minuteKey(ts) {
55
+ const d = new Date(ts)
56
+ const aligned = d.getMinutes() - (d.getMinutes() % MINUTE_BUCKET_SPAN_MINUTES)
57
+ return `${hourKey(ts)}:${pad(aligned)}`
58
+ }
59
+
60
+ // 同一样本依次落三粒度
61
+ export const GRANULARITIES = [
62
+ [GRANULARITY_DAILY, dayKey],
63
+ [GRANULARITY_HOURLY, hourKey],
64
+ [GRANULARITY_MINUTE, minuteKey],
65
+ ]
66
+
67
+ export function providerOf(modelRef) {
68
+ const slash = modelRef.indexOf('/')
69
+ return slash > 0 ? modelRef.slice(0, slash) : PROVIDER_DEFAULT
70
+ }
71
+
72
+ export const usageRowSchema = z.object({
73
+ bucket: z.string(),
74
+ provider: z.string(),
75
+ model: z.string(),
76
+ inputTokens: z.number(),
77
+ outputTokens: z.number(),
78
+ cacheReadTokens: z.number(),
79
+ cacheWriteTokens: z.number(),
80
+ requests: z.number(),
81
+ turns: z.number(),
82
+ lastSeen: z.number(),
83
+ })
84
+
85
+ export const usageStatsDomain = defineDomain({
86
+ name: DOMAIN_NAME,
87
+ version: 1,
88
+ tables: {
89
+ [TABLE_BUCKETS]: domainTable(usageRowSchema),
90
+ },
91
+ global: {
92
+ schema: z.object({
93
+ backfilledSessions: z.array(z.string()),
94
+ liveFirstSeq: z.record(z.string(), z.number()).optional(),
95
+ }),
96
+ initial: { backfilledSessions: [], liveFirstSeq: {} },
97
+ },
98
+ })
99
+
100
+ export function rowKey(g, bucket, provider, model) {
101
+ return `${g}|${bucket}|${provider}|${model}`
102
+ }
103
+
104
+ function emptyRow(bucket, provider, model, nowMs) {
105
+ return {
106
+ bucket,
107
+ provider,
108
+ model,
109
+ inputTokens: 0,
110
+ outputTokens: 0,
111
+ cacheReadTokens: 0,
112
+ cacheWriteTokens: 0,
113
+ requests: 0,
114
+ turns: 0,
115
+ lastSeen: nowMs,
116
+ }
117
+ }
118
+
119
+ function isMissingRecord(err) {
120
+ return err instanceof Error && MISSING_RECORD_PATTERN.test(err.message)
121
+ }
122
+
123
+ export class UsageStore {
124
+ constructor(facility, options = {}) {
125
+ this.now = options.now ?? Date.now
126
+ this.retentionDays = options.retentionDays ?? (() => DEFAULT_MINUTE_RETENTION_DAYS)
127
+ this.table = null
128
+ this.domain = null
129
+ this.openError = undefined
130
+ this.lastPruneDay = ''
131
+ this.markChain = Promise.resolve()
132
+ // ready 永远 resolve:打开失败转降级,操作按调用失败,逃逸拒绝会拖垮宿主
133
+ this.ready = this.initialize(facility).catch((err) => {
134
+ this.openError = err
135
+ })
136
+ }
137
+
138
+ async initialize(facility) {
139
+ const domain = await facility.open(usageStatsDomain)
140
+ this.domain = domain
141
+ this.table = domain.table(TABLE_BUCKETS)
142
+ // 游标空而已有行:无法区分半写坏态,丢弃行交由回扫重建
143
+ const cursor = this.readCursor()
144
+ if ((cursor?.backfilledSessions?.length ?? 0) === 0 && !this.table.keys().next().done) {
145
+ for (const key of [...this.table.keys()]) await this.table.delete(key)
146
+ }
147
+ }
148
+
149
+ get degradation() {
150
+ return this.openError
151
+ }
152
+
153
+ async readyPromise() {
154
+ await this.ready
155
+ }
156
+
157
+ readCursor() {
158
+ return this.domain?.global?.get()
159
+ }
160
+
161
+ degradedError() {
162
+ const detail = this.openError instanceof Error ? this.openError.message : String(this.openError)
163
+ return new Error(`usage store degraded (domain unavailable: ${detail})`)
164
+ }
165
+
166
+ requireTable() {
167
+ if (this.openError !== undefined) throw this.degradedError()
168
+ return this.table
169
+ }
170
+
171
+ requireCursor() {
172
+ if (this.openError !== undefined) throw this.degradedError()
173
+ return this.readCursor() ?? {}
174
+ }
175
+
176
+ async record(sample) {
177
+ await this.ready
178
+ const table = this.requireTable()
179
+ await this.pruneOncePerDay()
180
+ 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')
186
+ }
187
+
188
+ async pruneOncePerDay() {
189
+ const today = dayKey(this.now())
190
+ if (today === this.lastPruneDay) return
191
+ this.lastPruneDay = today
192
+ try {
193
+ await this.pruneMinutes()
194
+ await this.pruneHours()
195
+ } catch {
196
+ // 清理失败不阻断写入,次日首写再试
197
+ }
198
+ }
199
+
200
+ // days = 0 表示禁用分钟桶:全量清理而非按窗口保留
201
+ async pruneMinutes(days = this.retentionDays()) {
202
+ await this.ready
203
+ const table = this.requireTable()
204
+ const retention = clampMinuteRetentionDays(days)
205
+ const cutoff = retention === 0 ? null : minuteKey(this.now() - retention * DAY_MS)
206
+ for (const [key, existing] of table.entries()) {
207
+ if (!key.startsWith(MINUTE_KEY_PREFIX)) continue
208
+ if (cutoff === null || existing.bucket < cutoff) await table.delete(key)
209
+ }
210
+ }
211
+
212
+ // 小时桶保留固定 15 天,无配置项
213
+ async pruneHours() {
214
+ await this.ready
215
+ const table = this.requireTable()
216
+ const cutoff = hourKey(this.now() - HOUR_RETENTION_DAYS * DAY_MS)
217
+ for (const [key, existing] of table.entries()) {
218
+ if (!key.startsWith(HOUR_KEY_PREFIX)) continue
219
+ if (existing.bucket < cutoff) await table.delete(key)
220
+ }
221
+ }
222
+
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
+ async rangeRows(g, from, to) {
250
+ await this.ready
251
+ const table = this.requireTable()
252
+ const prefix = `${g}|`
253
+ const matched = []
254
+ for (const [key, existing] of table.entries()) {
255
+ if (!key.startsWith(prefix)) continue
256
+ if (existing.bucket >= from && existing.bucket <= to) matched.push(existing)
257
+ }
258
+ return matched
259
+ }
260
+
261
+ async seenSessions() {
262
+ await this.ready
263
+ return new Set(this.readCursor()?.backfilledSessions ?? [])
264
+ }
265
+
266
+ async liveSequences() {
267
+ await this.ready
268
+ return new Map(Object.entries(this.readCursor()?.liveFirstSeq ?? {}))
269
+ }
270
+
271
+ markSeenSessions(ids) {
272
+ return this.enqueueGlobalWrite(async () => {
273
+ const cursor = this.requireCursor()
274
+ const seen = new Set(cursor.backfilledSessions ?? [])
275
+ for (const id of ids) seen.add(id)
276
+ await this.writeGlobal([...seen], cursor.liveFirstSeq ?? {})
277
+ })
278
+ }
279
+
280
+ markLiveSequences(entries) {
281
+ return this.enqueueGlobalWrite(async () => {
282
+ const cursor = this.requireCursor()
283
+ const merged = { ...(cursor.liveFirstSeq ?? {}) }
284
+ let changed = false
285
+ for (const [id, seq] of entries) {
286
+ if (merged[id] === undefined || seq < merged[id]) {
287
+ merged[id] = seq
288
+ changed = true
289
+ }
290
+ }
291
+ if (!changed) return
292
+ await this.writeGlobal(cursor.backfilledSessions ?? [], merged)
293
+ })
294
+ }
295
+
296
+ reset(boundaries) {
297
+ return this.enqueueGlobalWrite(async () => {
298
+ const table = this.requireTable()
299
+ for (const key of [...table.keys()]) await table.delete(key)
300
+ const liveFirstSeq = {}
301
+ if (boundaries) for (const [id, seq] of boundaries) liveFirstSeq[id] = seq
302
+ await this.writeGlobal([], liveFirstSeq)
303
+ })
304
+ }
305
+
306
+ // global 只有整值覆写:全部游标写挂同一链,读改写不再交错
307
+ enqueueGlobalWrite(write) {
308
+ const pending = this.markChain.then(async () => {
309
+ await this.ready
310
+ await write()
311
+ })
312
+ this.markChain = pending.then(() => {}, () => {})
313
+ return pending
314
+ }
315
+
316
+ async writeGlobal(backfilledSessions, liveFirstSeq) {
317
+ await this.domain.global.set({ backfilledSessions, liveFirstSeq })
318
+ }
319
+ }
320
+
321
+ const SHARED_STORE_KEY = '__dshUsageDashStore'
322
+
323
+ export function sharedStore(facility, options = {}) {
324
+ const existing = globalThis[SHARED_STORE_KEY]
325
+ if (existing) return existing
326
+ const store = new UsageStore(facility, options)
327
+ globalThis[SHARED_STORE_KEY] = store
328
+ return store
329
+ }
330
+
331
+ export function __resetSharedStoreForTests() {
332
+ delete globalThis[SHARED_STORE_KEY]
333
+ }