dsh-all-usage 1.1.18 → 1.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/CHANGELOG.md +453 -394
- package/LICENSE +21 -21
- package/README.en.md +270 -0
- package/README.md +142 -279
- package/assets/model-icons/ai2-color.svg +1 -0
- package/assets/model-icons/ai21-brand-color.svg +1 -0
- package/assets/model-icons/aionlabs.svg +1 -0
- package/assets/model-icons/alephalpha.svg +1 -0
- package/assets/model-icons/baai.svg +1 -0
- package/assets/model-icons/baichuan-color.svg +1 -0
- package/assets/model-icons/baidu-color.svg +1 -0
- package/assets/model-icons/bytedance-color.svg +1 -0
- package/assets/model-icons/cohere-color.svg +1 -0
- package/assets/model-icons/hunyuan-color.svg +1 -0
- package/assets/model-icons/ibm.svg +1 -0
- package/assets/model-icons/iflytekcloud-color.svg +1 -0
- package/assets/model-icons/internlm-color.svg +1 -0
- package/assets/model-icons/kling-color.svg +1 -0
- package/assets/model-icons/liquid.svg +1 -0
- package/assets/model-icons/longcat-color.svg +1 -0
- package/assets/model-icons/manifest.json +739 -213
- package/assets/model-icons/microsoft-color.svg +1 -0
- package/assets/model-icons/mistral-color.svg +1 -0
- package/assets/model-icons/nousresearch.svg +1 -0
- package/assets/model-icons/nova-color.svg +1 -0
- package/assets/model-icons/nvidia-color.svg +1 -0
- package/assets/model-icons/perplexity-color.svg +1 -0
- package/assets/model-icons/sensenova-color.svg +1 -0
- package/assets/model-icons/skywork-color.svg +1 -0
- package/assets/model-icons/snowflake-color.svg +1 -0
- package/assets/model-icons/stepfun-color.svg +1 -0
- package/assets/model-icons/tencent-color.svg +1 -0
- package/assets/model-icons/tii-color.svg +1 -0
- package/assets/model-icons/upstage-color.svg +1 -0
- package/assets/model-icons/voyage-color.svg +1 -0
- package/assets/model-icons/xiaomimimo.svg +1 -0
- package/assets/model-icons/yandex.svg +1 -0
- package/assets/readme-architecture-en.svg +95 -0
- package/assets/readme-architecture-zh.svg +95 -0
- package/assets/readme-sync-en.svg +81 -0
- package/assets/readme-sync-zh.svg +81 -0
- package/assets/screenshots/en/01-overview.png +0 -0
- package/assets/screenshots/en/02-heatmap.png +0 -0
- package/assets/screenshots/en/03-model-usage.png +0 -0
- package/assets/screenshots/en/04-request-logs.png +0 -0
- package/assets/screenshots/en/05-cost-settings.png +0 -0
- package/assets/screenshots/zh/01-overview.png +0 -0
- package/assets/screenshots/zh/02-heatmap.png +0 -0
- package/assets/screenshots/zh/03-model-usage.png +0 -0
- package/assets/screenshots/zh/04-request-logs.png +0 -0
- package/assets/screenshots/zh/05-cost-settings.png +0 -0
- package/cordis.patch.yml +5 -5
- package/lib/aggregation.js +1842 -1112
- package/lib/client.js +1 -1
- package/lib/http.js +417 -385
- package/lib/icons.js +40 -0
- package/lib/ledger.js +1000 -531
- package/lib/plugin.js +347 -327
- package/lib/pricing-runtime.js +615 -551
- package/lib/pricing.js +1463 -1412
- package/lib/records-cache.js +113 -0
- package/lib/session-sync.js +976 -841
- package/lib/usage-core.js +33 -10
- package/lib/version-check.js +133 -133
- package/package.json +7 -3
- package/screenshots.json +10 -3
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
package/lib/ledger.js
CHANGED
|
@@ -1,531 +1,1000 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
async
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
1
|
+
import { readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { recordKeyHash, recordKeyLocator } from './records-cache.js'
|
|
4
|
+
import { calculateCost, normalizeCostSnapshot } from './pricing.js'
|
|
5
|
+
import { billingInstantOf, contextTimeKey, extractUsageEvent, normalizeUsageValues as usageValues, pickPricingTime, touchContextTimes, upsertUsageSample as upsertUsageSampleState, usageStepKey } from './usage-core.js'
|
|
6
|
+
|
|
7
|
+
// Usage recorded for workspaces that left the registry is summed into one
|
|
8
|
+
// bucket. The ledger's promise is that flushed usage survives deletion, and a
|
|
9
|
+
// row per dead workspace would grow the dashboard without adding information.
|
|
10
|
+
const RETIRED_WORKSPACE_ID = 'retired:deleted'
|
|
11
|
+
|
|
12
|
+
// Re-attribute a record (and its turns/usage items) to another workspace id.
|
|
13
|
+
// Only the in-memory copy is retargeted: the persisted row keeps its original
|
|
14
|
+
// workspace id and cwd, so the mapping stays reversible.
|
|
15
|
+
function retargetLedgerRecord(record, workspaceId = RETIRED_WORKSPACE_ID) {
|
|
16
|
+
if (record === null || record === undefined) return record
|
|
17
|
+
const retargeted = {
|
|
18
|
+
...record,
|
|
19
|
+
workspaceId,
|
|
20
|
+
turns: Array.isArray(record.turns) ? record.turns.map((turn) => ({ ...turn, workspaceId })) : record.turns,
|
|
21
|
+
usage: Array.isArray(record.usage) ? record.usage.map((item) => ({ ...item, workspaceId })) : record.usage,
|
|
22
|
+
}
|
|
23
|
+
// Pricing may later write a resident retired copy. Keep a non-enumerable pointer
|
|
24
|
+
// to the original durable shape so that write-back changes costs without erasing
|
|
25
|
+
// the historical workspace attribution.
|
|
26
|
+
Object.defineProperty(retargeted, '__durableRecord', {
|
|
27
|
+
value: record.__durableRecord || record,
|
|
28
|
+
enumerable: false,
|
|
29
|
+
configurable: true,
|
|
30
|
+
})
|
|
31
|
+
return retargeted
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LEDGER_VERSION = 3
|
|
35
|
+
const PREVIOUS_LEDGER_VERSION = 2
|
|
36
|
+
const LEGACY_LEDGER_VERSION = 1
|
|
37
|
+
const LEDGER_SHARD_COUNT = 32
|
|
38
|
+
const LEDGER_UNIT_NAME = 'all_usage_ledger'
|
|
39
|
+
const LEDGER_META_KEY = '__all_usage_ledger_meta__'
|
|
40
|
+
const LEDGER_WRITE_DEBOUNCE_MS = 25
|
|
41
|
+
|
|
42
|
+
// DSH 0.1.5 replaced the Session `events` getter with `snapshotEvents()` while
|
|
43
|
+
// 0.1.1 exposed `events`; both return the log in seq order. Read whichever the
|
|
44
|
+
// running runtime provides, because falling back to an empty log would fold a
|
|
45
|
+
// flushed session into an empty ledger record.
|
|
46
|
+
function sessionEvents(session) {
|
|
47
|
+
if (session === null || session === undefined) return []
|
|
48
|
+
if (Array.isArray(session.events)) return session.events
|
|
49
|
+
if (typeof session.snapshotEvents === 'function') {
|
|
50
|
+
try {
|
|
51
|
+
const snapshot = session.snapshotEvents()
|
|
52
|
+
if (Array.isArray(snapshot)) return snapshot
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return []
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return []
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Ledger sequences follow the DSH contract: -1 marks a missing/unavailable
|
|
61
|
+
// sequence, otherwise a non-negative safe integer is required.
|
|
62
|
+
function validLedgerSeq(value) {
|
|
63
|
+
return value === -1 || (Number.isSafeInteger(value) && value >= 0)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pollutedLedgerKey(key) {
|
|
67
|
+
// Older builds stored raw String(seq) in event keys. Only keys that are
|
|
68
|
+
// entirely numeric text (leading zeros, signs, whitespace, decimals,
|
|
69
|
+
// exponents, huge values) are treated as bare sequence keys and rebuilt;
|
|
70
|
+
// composite keys such as positional event:N and usageStepKey forms that
|
|
71
|
+
// contain colons, letters, or hyphens are never flagged.
|
|
72
|
+
if (typeof key !== 'string') return true
|
|
73
|
+
// Bare whole values only: composite keys such as 'Infinity-session:step:1:1'
|
|
74
|
+
// are valid usageStepKey output and must keep the revision fast path.
|
|
75
|
+
if (key === 'Infinity' || key === '-Infinity' || key === 'NaN' || key === '-NaN') return true
|
|
76
|
+
if (!/^[+\-0-9.eE \t]+$/.test(key)) return false
|
|
77
|
+
if (!/^(?:0|[1-9]\d*)$/.test(key)) return true
|
|
78
|
+
return !Number.isSafeInteger(Number(key))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ledgerShardIndex(sessionId) {
|
|
82
|
+
let hash = 2166136261
|
|
83
|
+
const value = typeof sessionId === 'string' ? sessionId : String(sessionId)
|
|
84
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
85
|
+
hash ^= value.charCodeAt(index)
|
|
86
|
+
hash = Math.imul(hash, 16777619)
|
|
87
|
+
}
|
|
88
|
+
return (hash >>> 0) % LEDGER_SHARD_COUNT
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ledgerShardName(index) {
|
|
92
|
+
return LEDGER_UNIT_NAME + '_' + String(index).padStart(2, '0')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sessionRows(snapshot) {
|
|
96
|
+
const rows = snapshot && snapshot.tables && snapshot.tables.sessions
|
|
97
|
+
return rows !== null && rows !== undefined && typeof rows === 'object' ? rows : {}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function uniqueUnits(units) {
|
|
101
|
+
return Array.from(new Set(units.filter((unit) => unit !== null && unit !== undefined)))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function createLedgerUnitStore(legacyUnit, shardUnits, root = '') {
|
|
105
|
+
const shards = uniqueUnits(shardUnits)
|
|
106
|
+
const allUnits = uniqueUnits([legacyUnit, ...shards])
|
|
107
|
+
return {
|
|
108
|
+
|
|
109
|
+
async loadAll() {
|
|
110
|
+
const snapshots = await Promise.all(shards.map((unit) => unit.loadAll()))
|
|
111
|
+
const rows = Object.create(null)
|
|
112
|
+
for (const snapshot of snapshots) {
|
|
113
|
+
for (const [key, value] of Object.entries(sessionRows(snapshot))) {
|
|
114
|
+
if (key !== LEDGER_META_KEY) rows[key] = value
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { global: {}, tables: { sessions: rows } }
|
|
118
|
+
},
|
|
119
|
+
/**
|
|
120
|
+
* Every session key the shards hold, without parsing a single record: the
|
|
121
|
+
* per-record layout is a directory of documents, so the filenames are the
|
|
122
|
+
* index and a record is read later, on demand, with readRecord().
|
|
123
|
+
*/
|
|
124
|
+
async listKeys() {
|
|
125
|
+
const keys = []
|
|
126
|
+
for (const unit of shards) {
|
|
127
|
+
let dir = ''
|
|
128
|
+
try {
|
|
129
|
+
dir = unit.tableDir('sessions')
|
|
130
|
+
} catch (err) {
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name.startsWith('__')) continue
|
|
136
|
+
keys.push(entry.name.slice(0, -'.json'.length))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return keys
|
|
140
|
+
},
|
|
141
|
+
/** One document, parsed the way the storage unit parses it when it loads. */
|
|
142
|
+
async readRecord(key) {
|
|
143
|
+
const unit = shardUnits[ledgerShardIndex(key)]
|
|
144
|
+
if (unit === undefined) return undefined
|
|
145
|
+
let path = ''
|
|
146
|
+
try {
|
|
147
|
+
path = join(unit.tableDir('sessions'), key + '.json')
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const doc = JSON.parse(await readFile(path, 'utf8'))
|
|
153
|
+
if (doc === null || typeof doc !== 'object' || doc.version !== 0 || doc.record === undefined) return undefined
|
|
154
|
+
return doc.record
|
|
155
|
+
} catch (err) {
|
|
156
|
+
return undefined
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
async hasMigrationMarker() {
|
|
160
|
+
if (shardUnits[0] === undefined) return false
|
|
161
|
+
return Object.hasOwn(sessionRows(await shardUnits[0].loadAll()), LEDGER_META_KEY)
|
|
162
|
+
},
|
|
163
|
+
async putRecord(table, key, value) {
|
|
164
|
+
if (table !== 'sessions') throw new Error('usage ledger only supports the sessions table')
|
|
165
|
+
const index = key === LEDGER_META_KEY ? 0 : ledgerShardIndex(key)
|
|
166
|
+
const unit = shardUnits[index]
|
|
167
|
+
if (unit === undefined) throw new Error('usage ledger shard is unavailable: ' + index)
|
|
168
|
+
return unit.putRecord(table, key, value)
|
|
169
|
+
},
|
|
170
|
+
async close() {
|
|
171
|
+
await Promise.all(allUnits.map((unit) => unit.close().catch(() => {})))
|
|
172
|
+
},
|
|
173
|
+
shardCount: LEDGER_SHARD_COUNT,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function openLedgerUnitStore(backend) {
|
|
178
|
+
const shardUnits = []
|
|
179
|
+
let legacyUnit
|
|
180
|
+
try {
|
|
181
|
+
for (let index = 0; index < LEDGER_SHARD_COUNT; index += 1) {
|
|
182
|
+
// One file per session record: the single-file layout rewrote the whole
|
|
183
|
+
// shard (a 4.9 MB average, 13.2 MB worst case, ~50-240 ms) for every
|
|
184
|
+
// session update. The per-record layout publishes one document per write,
|
|
185
|
+
// and it migrates itself from the same-named single-file unit on open.
|
|
186
|
+
shardUnits.push(await backend.kv.open({ name: ledgerShardName(index), version: 0, tables: ['sessions'], hasGlobal: false, layout: 'per-record' }))
|
|
187
|
+
}
|
|
188
|
+
const root = typeof backend.root === 'string' ? backend.root : ''
|
|
189
|
+
const shardStore = createLedgerUnitStore(undefined, shardUnits, root)
|
|
190
|
+
// A per-record unit exposes tableDir(), so its documents can be listed and read
|
|
191
|
+
// one at a time. Older or fake units cannot, and there the whole table is read
|
|
192
|
+
// once (and stays resident) exactly as it always was.
|
|
193
|
+
const streaming = shardUnits.length > 0 && typeof shardUnits[0].tableDir === 'function'
|
|
194
|
+
const shardRows = streaming ? Object.create(null) : sessionRows(await shardStore.loadAll())
|
|
195
|
+
if (await shardStore.hasMigrationMarker()) {
|
|
196
|
+
// The trees are the record now, so any leftover whole-unit file is dead
|
|
197
|
+
// weight. This also runs on the fast path, because a previous start may
|
|
198
|
+
// have seeded the trees and exited before retiring them.
|
|
199
|
+
const retired = await retireLegacyLedgerFiles(backend, shardUnits).catch((error) => { console.error('[all-usage] usage ledger legacy retirement failed:', error && error.message); return [] })
|
|
200
|
+
return { store: shardStore, rows: shardRows, retired, streaming }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Read the legacy monolith only during the one-time migration. Once shard 00
|
|
204
|
+
// carries the marker, startup never parses the old multi-megabyte file again.
|
|
205
|
+
legacyUnit = await backend.kv.open({ name: LEDGER_UNIT_NAME, version: 0, tables: ['sessions'], hasGlobal: false })
|
|
206
|
+
const store = createLedgerUnitStore(legacyUnit, shardUnits)
|
|
207
|
+
const rows = Object.assign(Object.create(null), shardRows)
|
|
208
|
+
const legacyRows = sessionRows(await legacyUnit.loadAll())
|
|
209
|
+
for (const [key, value] of Object.entries(legacyRows)) {
|
|
210
|
+
if (key === LEDGER_META_KEY || Object.hasOwn(rows, key)) continue
|
|
211
|
+
await store.putRecord('sessions', key, value)
|
|
212
|
+
rows[key] = value
|
|
213
|
+
}
|
|
214
|
+
await store.putRecord('sessions', LEDGER_META_KEY, { version: 1, migratedAt: Date.now() })
|
|
215
|
+
// Its records now live in the per-record trees (written above), so the
|
|
216
|
+
// legacy document is retired. A failure here leaves the file in place and
|
|
217
|
+
// simply tries again on the next start.
|
|
218
|
+
const retired = await retireLegacyLedgerFiles(backend, shardUnits).catch((error) => { console.error('[all-usage] usage ledger legacy retirement failed:', error && error.message); return [] })
|
|
219
|
+
return { store, rows, retired, streaming: false }
|
|
220
|
+
} catch (error) {
|
|
221
|
+
await Promise.all(uniqueUnits([legacyUnit, ...shardUnits]).map((unit) => unit.close().catch(() => {})))
|
|
222
|
+
throw error
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Once a legacy whole-unit file has seeded its per-record tree, the file itself
|
|
227
|
+
// is dead weight: every record lives in the tree now, while the old document
|
|
228
|
+
// keeps a full copy (155.9 MB across the 32 shards plus a 22.4 MB monolith on
|
|
229
|
+
// this machine). DSH's storage layer never removes it, so the ledger retires it
|
|
230
|
+
// itself — only after the tree is non-empty, only for files it owns, and never
|
|
231
|
+
// more than once per unit (a marker keeps a failed run from looping).
|
|
232
|
+
async function retireLegacyLedgerFiles(backend, shardUnits) {
|
|
233
|
+
if (process.env.DSH_ALL_USAGE_KEEP_LEGACY === '1') return []
|
|
234
|
+
const root = typeof backend.root === 'string' ? backend.root : ''
|
|
235
|
+
if (root === '') return []
|
|
236
|
+
const marker = join(root, LEDGER_UNIT_NAME + '.retired')
|
|
237
|
+
if (await stat(marker).then(() => true, () => false)) return []
|
|
238
|
+
const retired = []
|
|
239
|
+
const candidates = []
|
|
240
|
+
for (let index = 0; index < LEDGER_SHARD_COUNT; index += 1) {
|
|
241
|
+
const name = ledgerShardName(index)
|
|
242
|
+
if (shardUnits[index] === undefined || shardUnits[index] === null) continue
|
|
243
|
+
// The medium tells the truth: a shard has been migrated when its per-record
|
|
244
|
+
// tree holds documents.
|
|
245
|
+
const count = await readdir(join(root, name, 'sessions')).then((entries) => entries.filter((entry) => entry.endsWith('.json')).length, () => 0)
|
|
246
|
+
candidates.push({ name, count })
|
|
247
|
+
}
|
|
248
|
+
for (const candidate of candidates) {
|
|
249
|
+
if (candidate.count === 0) continue
|
|
250
|
+
const file = join(root, candidate.name + '.json')
|
|
251
|
+
const size = await stat(file).then((info) => info.size, () => null)
|
|
252
|
+
if (size === null) continue
|
|
253
|
+
const removed = await unlink(file).then(() => size, (error) => { console.error('[all-usage] usage ledger legacy file could not be retired:', file, error && error.message); return null })
|
|
254
|
+
if (removed !== null) retired.push({ name: candidate.name + '.json', bytes: removed })
|
|
255
|
+
}
|
|
256
|
+
const monolith = join(root, LEDGER_UNIT_NAME + '.json')
|
|
257
|
+
const monolithSize = await stat(monolith).then((info) => info.size, () => null)
|
|
258
|
+
if (monolithSize !== null && candidates.some((candidate) => candidate.count > 0)) {
|
|
259
|
+
const removed = await unlink(monolith).then(() => monolithSize, () => null)
|
|
260
|
+
if (removed !== null) retired.push({ name: LEDGER_UNIT_NAME + '.json', bytes: removed })
|
|
261
|
+
}
|
|
262
|
+
if (retired.length > 0) await writeFile(marker, JSON.stringify({ retiredAt: Date.now(), files: retired.length }), 'utf8').catch(() => {})
|
|
263
|
+
return retired
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const IDENTITY_REF_FIELD = 'identityRef'
|
|
267
|
+
|
|
268
|
+
// The same route is used for thousands of requests, yet a ledger row used to
|
|
269
|
+
// repeat its identity object (key, provider, requested/actual model, label) on
|
|
270
|
+
// every usage item — 14.5 MB of the current 91 MB of records. Rows are written
|
|
271
|
+
// with one shared identity list and per-item indexes instead, and read back into
|
|
272
|
+
// the same shape as before, so nothing outside this file changes. Rows whose
|
|
273
|
+
// workspace differs from the record's keep their own workspaceId, and a row with
|
|
274
|
+
// no identity at all still normalizes through its modelId.
|
|
275
|
+
function serializeLedgerRecord(record) {
|
|
276
|
+
if (record === null || typeof record !== 'object' || !Array.isArray(record.usage)) return record
|
|
277
|
+
const identities = []
|
|
278
|
+
const identityIndex = new Map()
|
|
279
|
+
const usage = record.usage.map((item) => {
|
|
280
|
+
if (item === null || typeof item !== 'object') return item
|
|
281
|
+
const identity = item.identity
|
|
282
|
+
if (identity === undefined || identity === null) return item
|
|
283
|
+
const identityKey = JSON.stringify(identity)
|
|
284
|
+
let index = identityIndex.get(identityKey)
|
|
285
|
+
if (index === undefined) { index = identities.length; identities.push(identity); identityIndex.set(identityKey, index) }
|
|
286
|
+
// workspaceId stays on every item: it is what tells a mixed-workspace row
|
|
287
|
+
// apart, and a record retargeted to another workspace must not re-home its
|
|
288
|
+
// history.
|
|
289
|
+
const next = { ...item, [IDENTITY_REF_FIELD]: index }
|
|
290
|
+
delete next.identity
|
|
291
|
+
delete next.modelId
|
|
292
|
+
return next
|
|
293
|
+
})
|
|
294
|
+
if (identities.length === 0) return { ...record, usage }
|
|
295
|
+
return { ...record, usageIdentities: identities, usage }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function expandLedgerRecord(raw) {
|
|
299
|
+
if (raw === null || typeof raw !== 'object' || !Array.isArray(raw.usage) || !Array.isArray(raw.usageIdentities)) return raw
|
|
300
|
+
const identities = raw.usageIdentities
|
|
301
|
+
const usage = raw.usage.map((item) => {
|
|
302
|
+
if (item === null || typeof item !== 'object' || !Number.isInteger(item[IDENTITY_REF_FIELD])) return item
|
|
303
|
+
const identity = identities[item[IDENTITY_REF_FIELD]]
|
|
304
|
+
const next = { ...item, identity, modelId: identity === undefined || identity === null ? item.modelId : identity.label }
|
|
305
|
+
delete next[IDENTITY_REF_FIELD]
|
|
306
|
+
return next
|
|
307
|
+
})
|
|
308
|
+
const expanded = { ...raw, usage }
|
|
309
|
+
delete expanded.usageIdentities
|
|
310
|
+
return expanded
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function createLedger(host) {
|
|
314
|
+
const { state, markStatsChanged } = host
|
|
315
|
+
const { storage } = host.services
|
|
316
|
+
state.ledgerPending = state.ledgerPending || new Map()
|
|
317
|
+
state.ledgerReading = state.ledgerReading || new Map()
|
|
318
|
+
state.ledgerWriting = state.ledgerWriting || new Set()
|
|
319
|
+
state.ledgerWriteWaiters = state.ledgerWriteWaiters || new Map()
|
|
320
|
+
state.ledgerWriteRunning = state.ledgerWriteRunning === true
|
|
321
|
+
state.ledgerWriteScheduled = state.ledgerWriteScheduled === true
|
|
322
|
+
state.ledgerWriteTimer = state.ledgerWriteTimer || null
|
|
323
|
+
state.ledgerWriteFailedSessions = state.ledgerWriteFailedSessions || new Set()
|
|
324
|
+
const {
|
|
325
|
+
addTurn,
|
|
326
|
+
coerceIdentity,
|
|
327
|
+
identityFromLegacy,
|
|
328
|
+
identityFromMessage,
|
|
329
|
+
identityFromRoute,
|
|
330
|
+
makeIdentity,
|
|
331
|
+
costForUsage,
|
|
332
|
+
resolveCurrentPricing,
|
|
333
|
+
dateKeys,
|
|
334
|
+
validEventTime,
|
|
335
|
+
upsertUsageSample,
|
|
336
|
+
usageRowOf,
|
|
337
|
+
foldUsageRow,
|
|
338
|
+
adoptUsageRow,
|
|
339
|
+
sessionsForRange,
|
|
340
|
+
} = host.aggregation
|
|
341
|
+
const lastSeqOf = (...args) => host.sessionSync.lastSeqOf(...args)
|
|
342
|
+
function isLegacyTieredCost(cost) {
|
|
343
|
+
const selectedTier = cost && cost.selectedTier
|
|
344
|
+
const hasSelectedTier = selectedTier && selectedTier.type === 'context' && Number.isSafeInteger(selectedTier.size) && selectedTier.size >= 0
|
|
345
|
+
return cost !== null && cost.tiered === true && cost.status === 'priced' && !hasSelectedTier
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function nextLedgerRevision() {
|
|
349
|
+
const current = Number.isFinite(state.ledgerRevision) ? state.ledgerRevision : 0
|
|
350
|
+
state.ledgerRevision = Math.max(current + 1, Date.now())
|
|
351
|
+
return state.ledgerRevision
|
|
352
|
+
}
|
|
353
|
+
function ledgerEventKey(event, index) {
|
|
354
|
+
// Only finite safe-integer sequences are canonical event ids; anything else
|
|
355
|
+
// falls back to the positional index so two invalid events cannot overwrite
|
|
356
|
+
// each other in the ledger.
|
|
357
|
+
return Number.isSafeInteger(event.seq) && event.seq >= 0 ? String(event.seq) : 'event:' + index
|
|
358
|
+
}
|
|
359
|
+
function canFoldLedgerTail(previousRecord, events, workspaceId) {
|
|
360
|
+
if (previousRecord === null || previousRecord === undefined || previousRecord.needsUpgrade === true || previousRecord.rebuildRequired !== undefined || !Number.isSafeInteger(previousRecord.lastSeq) || previousRecord.lastSeq < 0 || !Array.isArray(events) || events.length === 0) return false
|
|
361
|
+
// A changed workspace identity (deleted and re-added workspace gets a new id)
|
|
362
|
+
// assigns every historical event to a different bucket, so the previous
|
|
363
|
+
// record can never be reused as a tail seed for the new workspace.
|
|
364
|
+
if (workspaceId !== undefined && previousRecord.workspaceId !== undefined && previousRecord.workspaceId !== workspaceId) return false
|
|
365
|
+
const startIndex = previousRecord.lastSeq + 1
|
|
366
|
+
if (startIndex >= events.length || events[startIndex] === null || typeof events[startIndex] !== 'object' || events[startIndex].seq !== startIndex) return false
|
|
367
|
+
const previousTurns = new Set(Array.isArray(previousRecord.turns) ? previousRecord.turns.map((turn) => turn && turn.turn).filter((turn) => Number.isSafeInteger(turn)) : [])
|
|
368
|
+
for (let index = startIndex, expectedSeq = startIndex; index < events.length; index += 1, expectedSeq += 1) {
|
|
369
|
+
const event = events[index]
|
|
370
|
+
if (event === null || typeof event !== 'object' || event.seq !== expectedSeq) return false
|
|
371
|
+
if (event.type === 'turn/end' && event.data && Number.isSafeInteger(event.data.turn) && previousTurns.has(event.data.turn)) return false
|
|
372
|
+
}
|
|
373
|
+
return true
|
|
374
|
+
}
|
|
375
|
+
function buildLedgerRecord(session, workspaceId, source = 'scan', revision, previousRecord, sequenceClean = true) {
|
|
376
|
+
const sid = session && typeof session.id === 'string' ? session.id : ''
|
|
377
|
+
const events = sessionEvents(session)
|
|
378
|
+
if (sid === '' || workspaceId === undefined) return null
|
|
379
|
+
// DSH sequences every persisted event with seq === log index, so the last
|
|
380
|
+
// event's seq is the record's lastSeq in O(1); fall back to a full scan only
|
|
381
|
+
// when the tail carries no seq (legacy or truncated logs).
|
|
382
|
+
const tailEvent = events.length > 0 ? events[events.length - 1] : null
|
|
383
|
+
const lastSeq = tailEvent !== null && Number.isSafeInteger(tailEvent.seq) && tailEvent.seq >= 0 ? tailEvent.seq : lastSeqOf(events)
|
|
384
|
+
// A changed incremental log that contains invalid sequences anywhere must
|
|
385
|
+
// be rebuilt wholesale: the fold check only validates the tail, so a bad
|
|
386
|
+
// event before the fold start would otherwise be skipped in memory while
|
|
387
|
+
// the persisted record still contains everything.
|
|
388
|
+
const canFoldTail = sequenceClean === true && canFoldLedgerTail(previousRecord, events, workspaceId)
|
|
389
|
+
const turns = canFoldTail ? new Map((previousRecord.turns || []).map((item) => [item.key, { ...item }])) : new Map()
|
|
390
|
+
const usage = canFoldTail ? new Map((previousRecord.usage || []).map((item) => [item.key, { ...item }])) : new Map()
|
|
391
|
+
const previousUsage = new Map(Array.isArray(previousRecord && previousRecord.usage) ? previousRecord.usage.map((item) => [item.key, item]) : [])
|
|
392
|
+
let currentIdentity = canFoldTail ? coerceIdentity(previousRecord.lastIdentity) : makeIdentity(null, null, null, null)
|
|
393
|
+
// Request-context instants are kept per turn/step so parallel requests each
|
|
394
|
+
// carry their own billing time while scanning the persisted log. When the
|
|
395
|
+
// tail is folded onto a persisted record, the previous context archive seeds
|
|
396
|
+
// the map so an open request whose context arrived in the previous batch
|
|
397
|
+
// still bills its usage with the matching request instant after a restart.
|
|
398
|
+
const contextTimes = canFoldTail ? new Map(Array.isArray(previousRecord.contextTimes) ? previousRecord.contextTimes.map((entry) => [entry.key, entry.time]) : []) : new Map()
|
|
399
|
+
const startIndex = canFoldTail ? previousRecord.lastSeq + 1 : 0
|
|
400
|
+
for (let index = startIndex; index < events.length; index += 1) {
|
|
401
|
+
const event = events[index]
|
|
402
|
+
if (event === null || typeof event !== 'object') continue
|
|
403
|
+
if (canFoldTail && event.seq <= previousRecord.lastSeq) continue
|
|
404
|
+
const data = event.data
|
|
405
|
+
if (event.type === 'request/context') {
|
|
406
|
+
currentIdentity = identityFromRoute(data, currentIdentity)
|
|
407
|
+
if (validEventTime(event.time)) touchContextTimes(contextTimes, contextTimeKey(data && data.turn, data && data.step), event.time)
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
if (event.type === 'request/header') {
|
|
411
|
+
currentIdentity = identityFromRoute(data, currentIdentity)
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
if (event.type === 'turn/end') {
|
|
415
|
+
if (!validEventTime(event.time)) continue
|
|
416
|
+
const key = ledgerEventKey(event, index)
|
|
417
|
+
const turn = data && typeof data.turn === 'number' ? data.turn : null
|
|
418
|
+
turns.set(key, { key, seq: validLedgerSeq(event.seq) ? event.seq : -1, time: event.time, workspaceId, turn, identity: currentIdentity })
|
|
419
|
+
continue
|
|
420
|
+
}
|
|
421
|
+
if (!validEventTime(event.time)) continue
|
|
422
|
+
const usageEvent = extractUsageEvent(event)
|
|
423
|
+
if (usageEvent === null) continue
|
|
424
|
+
const identity = usageEvent.kind === 'message' ? identityFromMessage(data, currentIdentity) : currentIdentity
|
|
425
|
+
const key = usageStepKey(sid, data, event.seq)
|
|
426
|
+
const pricing = pickPricingTime(contextTimes, event.time, usageEvent.turn, usageEvent.step)
|
|
427
|
+
const result = upsertUsageSample(usage, {
|
|
428
|
+
key,
|
|
429
|
+
seq: validLedgerSeq(event.seq) ? event.seq : -1,
|
|
430
|
+
time: event.time,
|
|
431
|
+
workspaceId,
|
|
432
|
+
identity,
|
|
433
|
+
modelId: identity.label,
|
|
434
|
+
turn: usageEvent.turn,
|
|
435
|
+
step: usageEvent.step,
|
|
436
|
+
values: usageEvent.values,
|
|
437
|
+
pricingAt: pricing.time,
|
|
438
|
+
pricingTimeSource: pricing.source,
|
|
439
|
+
}, { costPrevious: previousUsage.get(key) })
|
|
440
|
+
if (result.accepted && usageEvent.kind === 'message') currentIdentity = identity
|
|
441
|
+
}
|
|
442
|
+
const sourceCwd = session && session.header && typeof session.header.cwd === 'string' ? session.header.cwd : undefined
|
|
443
|
+
return { version: LEDGER_VERSION, sessionId: sid, workspaceId, ...(sourceCwd === undefined ? {} : { sourceCwd }), lastSeq, source, updatedAt: nextLedgerRevision(), lastRevision: typeof revision === 'string' ? revision : undefined, sourceRevision: typeof revision === 'string' ? revision : undefined, lastIdentity: currentIdentity, contextTimes: Array.from(contextTimes.entries()).map(([key, time]) => ({ key, time })), turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
|
|
444
|
+
}
|
|
445
|
+
function normalizeLedgerRecord(input, key) {
|
|
446
|
+
// Rows written with a shared identity list are expanded first: every check
|
|
447
|
+
// below then sees exactly the same item shape it always did.
|
|
448
|
+
const raw = expandLedgerRecord(input)
|
|
449
|
+
if (raw === null || typeof raw !== 'object' || (raw.version !== LEDGER_VERSION && raw.version !== PREVIOUS_LEDGER_VERSION && raw.version !== LEGACY_LEDGER_VERSION) || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
|
|
450
|
+
if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
|
|
451
|
+
// One pass per list: validity, workspace mixing and the map share it, and a
|
|
452
|
+
// usage row normalizes its cost snapshot once instead of once per question
|
|
453
|
+
// (that call parses eleven decimal fields).
|
|
454
|
+
let invalidTurn = false
|
|
455
|
+
let invalidUsage = false
|
|
456
|
+
let mixedWorkspace = false
|
|
457
|
+
const turnMap = new Map()
|
|
458
|
+
for (const turn of raw.turns) {
|
|
459
|
+
if (turn === null || typeof turn !== 'object' || typeof turn.key !== 'string' || turn.workspaceId === undefined || !validEventTime(turn.time) || pollutedLedgerKey(turn.key) || (turn.seq !== undefined && !validLedgerSeq(turn.seq))) invalidTurn = true
|
|
460
|
+
if (turn && turn.workspaceId !== raw.workspaceId) mixedWorkspace = true
|
|
461
|
+
if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && validEventTime(turn.time)) turnMap.set(turn.key, { key: turn.key, seq: Number.isSafeInteger(turn.seq) ? turn.seq : -1, time: turn.time, workspaceId: turn.workspaceId, turn: typeof turn.turn === 'number' ? turn.turn : null, identity: coerceIdentity(turn.identity || turn.modelId) })
|
|
462
|
+
}
|
|
463
|
+
const usageMap = new Map()
|
|
464
|
+
for (const item of raw.usage) {
|
|
465
|
+
const normalizedCost = normalizeCostSnapshot(item === null || item === undefined ? null : item.cost)
|
|
466
|
+
if (item === null || typeof item !== 'object' || !validEventTime(item.time) || item.identity === undefined || pollutedLedgerKey(item.key) || (item.seq !== undefined && !validLedgerSeq(item.seq)) || normalizedCost === null || normalizedCost.pricingMode !== 'official-model' || isLegacyTieredCost(normalizedCost)) invalidUsage = true
|
|
467
|
+
// Older versions could write a record whose top-level workspaceId belonged
|
|
468
|
+
// to a recreated workspace while its historical items kept the previous one;
|
|
469
|
+
// such mixed records must never be reused as a fast path.
|
|
470
|
+
if (item && item.workspaceId !== raw.workspaceId) mixedWorkspace = true
|
|
471
|
+
if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || !validEventTime(item.time) || item.values === null || typeof item.values !== 'object') continue
|
|
472
|
+
const identity = coerceIdentity(item.identity || item.modelId)
|
|
473
|
+
const normalized = { key: item.key, seq: Number.isSafeInteger(item.seq) ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, identity, modelId: identity.label, ...(normalizedCost === null ? {} : { cost: normalizedCost }), turn: typeof item.turn === 'number' ? item.turn : null, step: typeof item.step === 'number' ? item.step : null, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
|
|
474
|
+
upsertUsageSampleState(usageMap, normalized)
|
|
475
|
+
}
|
|
476
|
+
// The rebuild reason is persisted (enumerable) so the flag survives a
|
|
477
|
+
// failure-and-restart cycle even when normalized content alone no longer
|
|
478
|
+
// carries the evidence (e.g. lastSeq -1 after an invalid non-safe value).
|
|
479
|
+
const persistedReason = typeof raw.rebuildRequired === 'string' && raw.rebuildRequired !== '' ? raw.rebuildRequired : undefined
|
|
480
|
+
const computedReason = invalidTurn ? 'invalid-turn' : invalidUsage ? 'invalid-usage' : mixedWorkspace ? 'mixed-workspace' : (raw.lastSeq !== undefined && !validLedgerSeq(raw.lastSeq)) ? 'invalid-last-seq' : raw.version !== LEDGER_VERSION ? 'legacy-version' : !Number.isFinite(raw.updatedAt) ? 'invalid-updated-at' : undefined
|
|
481
|
+
const rebuildReason = persistedReason !== undefined ? persistedReason : computedReason
|
|
482
|
+
const needsUpgrade = rebuildReason !== undefined || !Number.isFinite(raw.updatedAt) || raw.version !== LEDGER_VERSION
|
|
483
|
+
const updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0
|
|
484
|
+
state.ledgerRevision = Math.max(Number.isFinite(state.ledgerRevision) ? state.ledgerRevision : 0, updatedAt)
|
|
485
|
+
const contextMap = new Map()
|
|
486
|
+
if (Array.isArray(raw.contextTimes)) {
|
|
487
|
+
for (const entry of raw.contextTimes) {
|
|
488
|
+
if (entry === null || typeof entry !== 'object' || typeof entry.key !== 'string' || !entry.key.startsWith('context:') || entry.key.length > 128 || !validEventTime(entry.time)) continue
|
|
489
|
+
touchContextTimes(contextMap, entry.key, entry.time)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const lastUsage = Array.from(usageMap.values()).at(-1)
|
|
493
|
+
const lastIdentity = coerceIdentity(raw.lastIdentity || raw.sourceRevisionIdentity || (lastUsage && lastUsage.identity))
|
|
494
|
+
const sourceCwd = typeof raw.sourceCwd === 'string' && raw.sourceCwd.trim() !== '' ? raw.sourceCwd : undefined
|
|
495
|
+
const normalizedRecord = { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, ...(sourceCwd === undefined ? {} : { sourceCwd }), lastSeq: validLedgerSeq(raw.lastSeq) ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, lastRevision: typeof raw.lastRevision === 'string' ? raw.lastRevision : (typeof raw.sourceRevision === 'string' ? raw.sourceRevision : undefined), sourceRevision: typeof raw.sourceRevision === 'string' ? raw.sourceRevision : (typeof raw.lastRevision === 'string' ? raw.lastRevision : undefined), lastIdentity, contextTimes: Array.from(contextMap.entries()).map(([key, time]) => ({ key, time })), turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
|
|
496
|
+
if (rebuildReason !== undefined) normalizedRecord.rebuildRequired = rebuildReason
|
|
497
|
+
Object.defineProperty(normalizedRecord, 'needsUpgrade', { value: needsUpgrade, enumerable: false, writable: true })
|
|
498
|
+
return normalizedRecord
|
|
499
|
+
}
|
|
500
|
+
function prepareLedgerRecord(record) {
|
|
501
|
+
if (record === null || record === undefined) return record
|
|
502
|
+
// A current record was normalized on the way in, so every item already carries
|
|
503
|
+
// a canonical cost snapshot: re-parsing each one (eleven decimal fields apiece)
|
|
504
|
+
// would be a pass over the whole record that changes nothing.
|
|
505
|
+
if (record.needsUpgrade !== true && record.version === LEDGER_VERSION) return record
|
|
506
|
+
let changed = true
|
|
507
|
+
for (const item of record.usage) {
|
|
508
|
+
const cost = normalizeCostSnapshot(item.cost)
|
|
509
|
+
if (isLegacyTieredCost(cost)) {
|
|
510
|
+
item.cost = calculateCost(item.values, { ...cost, status: 'unsupported', reason: 'tiered-pricing-not-modeled' })
|
|
511
|
+
changed = true
|
|
512
|
+
continue
|
|
513
|
+
}
|
|
514
|
+
if (cost !== null && cost.pricingMode === 'official-model') { item.cost = cost; continue }
|
|
515
|
+
const identity = item.identity || identityFromLegacy(item.modelId)
|
|
516
|
+
const billing = billingInstantOf(item, cost)
|
|
517
|
+
item.cost = calculateCost(item.values, resolveCurrentPricing(identity), billing.at, billing.source)
|
|
518
|
+
changed = true
|
|
519
|
+
}
|
|
520
|
+
if (changed) {
|
|
521
|
+
// Repacking costs must never clear the rebuild flag: only a complete
|
|
522
|
+
// rebuild from the source log produces a record that is safe to reuse.
|
|
523
|
+
record.version = LEDGER_VERSION
|
|
524
|
+
record.updatedAt = nextLedgerRevision()
|
|
525
|
+
cacheLedgerRecord(record)
|
|
526
|
+
rememberLedgerKey(record.sessionId)
|
|
527
|
+
void persistLedgerRecord(record)
|
|
528
|
+
}
|
|
529
|
+
return record
|
|
530
|
+
}
|
|
531
|
+
function applyLedgerRecord(record, materialization = 'ledger-reuse') {
|
|
532
|
+
if (record === null || record === undefined) return
|
|
533
|
+
prepareLedgerRecord(record)
|
|
534
|
+
if (record.lastIdentity !== undefined) state.sessionModel.set(record.sessionId, record.lastIdentity)
|
|
535
|
+
if (Array.isArray(record.contextTimes)) {
|
|
536
|
+
const times = new Map()
|
|
537
|
+
for (const entry of record.contextTimes) if (entry && typeof entry.key === 'string' && Number.isFinite(entry.time)) times.set(entry.key, entry.time)
|
|
538
|
+
state.sessionContextTimes.set(record.sessionId, times)
|
|
539
|
+
}
|
|
540
|
+
for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId, turn.turn, turn.identity, materialization, turn.seq)
|
|
541
|
+
for (const item of record.usage) {
|
|
542
|
+
foldUsageRow(usageRowOf(record, item, materialization), materialization)
|
|
543
|
+
}
|
|
544
|
+
if (record.turns.length > 0 || record.usage.length > 0) {
|
|
545
|
+
state.sessionCount.add(record.sessionId)
|
|
546
|
+
if (record.usage.length > 0) markStatsChanged('scan')
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function ledgerRank(record) {
|
|
550
|
+
return [Number.isFinite(record.lastSeq) ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, Number.isFinite(record.updatedAt) ? record.updatedAt : 0]
|
|
551
|
+
}
|
|
552
|
+
function replaceLedgerRecord(record) {
|
|
553
|
+
assertLedgerRecord(record)
|
|
554
|
+
cacheLedgerRecord(record)
|
|
555
|
+
rememberLedgerKey(record.sessionId)
|
|
556
|
+
return record
|
|
557
|
+
}
|
|
558
|
+
function assertLedgerRecord(record) {
|
|
559
|
+
if (record === null || typeof record !== 'object' || !Number.isFinite(record.updatedAt)) throw new TypeError('usage ledger updatedAt must be a finite number')
|
|
560
|
+
return record
|
|
561
|
+
}
|
|
562
|
+
function storeLedgerRecord(record) {
|
|
563
|
+
assertLedgerRecord(record)
|
|
564
|
+
const current = state.ledgerRecords.get(record.sessionId)
|
|
565
|
+
if (current !== undefined) {
|
|
566
|
+
const nextRank = ledgerRank(record)
|
|
567
|
+
const currentRank = ledgerRank(current)
|
|
568
|
+
if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
|
|
569
|
+
}
|
|
570
|
+
cacheLedgerRecord(record)
|
|
571
|
+
rememberLedgerKey(record.sessionId)
|
|
572
|
+
return record
|
|
573
|
+
}
|
|
574
|
+
function resolveLedgerWaiters(sid) {
|
|
575
|
+
const waiters = state.ledgerWriteWaiters.get(sid)
|
|
576
|
+
if (waiters === undefined) return
|
|
577
|
+
state.ledgerWriteWaiters.delete(sid)
|
|
578
|
+
for (const resolve of waiters) resolve()
|
|
579
|
+
}
|
|
580
|
+
async function drainPendingLedgerWrites() {
|
|
581
|
+
while (state.ledgerPending.size > 0) {
|
|
582
|
+
const entry = state.ledgerPending.entries().next().value
|
|
583
|
+
if (entry === undefined) break
|
|
584
|
+
const [sid, record] = entry
|
|
585
|
+
state.ledgerPending.delete(sid)
|
|
586
|
+
const resident = state.ledgerRecords.get(sid)
|
|
587
|
+
if (state.ledgerUnit === null || (resident !== record && resident?.__durableRecord !== record)) {
|
|
588
|
+
if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
591
|
+
// Pending ends before I/O begins. Keep this session resident until the
|
|
592
|
+
// write settles, so a cold read cannot restore the older durable record.
|
|
593
|
+
state.ledgerWriting.add(sid)
|
|
594
|
+
try {
|
|
595
|
+
// The medium receives the compact row; everything in memory keeps the record.
|
|
596
|
+
await state.ledgerUnit.putRecord('sessions', sid, serializeLedgerRecord(record))
|
|
597
|
+
state.ledgerWriteFailedSessions.delete(sid)
|
|
598
|
+
} catch (err) {
|
|
599
|
+
state.ledgerWriteFailedSessions = state.ledgerWriteFailedSessions || new Set()
|
|
600
|
+
state.ledgerWriteFailedSessions.add(sid)
|
|
601
|
+
state.ledgerDirtySessions = state.ledgerDirtySessions || new Set()
|
|
602
|
+
state.ledgerDirtyEpochs = state.ledgerDirtyEpochs || new Map()
|
|
603
|
+
state.ledgerDirtySessions.add(sid)
|
|
604
|
+
state.ledgerDirtyEpochs.set(sid, (state.ledgerDirtyEpochs.get(sid) || 0) + 1)
|
|
605
|
+
console.error('[all-usage] usage ledger write failed:', err)
|
|
606
|
+
} finally {
|
|
607
|
+
state.ledgerWriting.delete(sid)
|
|
608
|
+
}
|
|
609
|
+
if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function startLedgerWrite() {
|
|
613
|
+
if (state.ledgerWriteRunning || state.ledgerPending.size === 0 || state.ledgerUnit === null) return
|
|
614
|
+
state.ledgerWriteRunning = true
|
|
615
|
+
const write = state.ledgerWriteChain.then(() => drainPendingLedgerWrites(), () => drainPendingLedgerWrites())
|
|
616
|
+
state.ledgerWriteChain = write.catch((err) => {
|
|
617
|
+
console.error('[all-usage] usage ledger queue failed:', err)
|
|
618
|
+
}).finally(() => {
|
|
619
|
+
state.ledgerWriteRunning = false
|
|
620
|
+
if (state.ledgerPending.size > 0 && state.ledgerUnit !== null) scheduleLedgerWrite()
|
|
621
|
+
})
|
|
622
|
+
}
|
|
623
|
+
function scheduleLedgerWrite() {
|
|
624
|
+
if (state.ledgerWriteScheduled || state.ledgerWriteRunning || state.ledgerPending.size === 0 || state.ledgerUnit === null) return
|
|
625
|
+
state.ledgerWriteScheduled = true
|
|
626
|
+
const run = () => {
|
|
627
|
+
state.ledgerWriteScheduled = false
|
|
628
|
+
state.ledgerWriteTimer = null
|
|
629
|
+
startLedgerWrite()
|
|
630
|
+
}
|
|
631
|
+
state.ledgerWriteTimer = setTimeout(run, LEDGER_WRITE_DEBOUNCE_MS)
|
|
632
|
+
// Keep a reference while write waiters are pending: the debounce timer is
|
|
633
|
+
// what eventually resolves persistLedgerRecord promises, and an unreferenced
|
|
634
|
+
// timer lets the event loop (and test runs) settle before the write happens.
|
|
635
|
+
if (state.ledgerWriteTimer && typeof state.ledgerWriteTimer.unref === 'function' && state.ledgerWriteWaiters.size === 0) state.ledgerWriteTimer.unref()
|
|
636
|
+
}
|
|
637
|
+
function persistLedgerRecord(record) {
|
|
638
|
+
if (record !== null && record !== undefined) assertLedgerRecord(record)
|
|
639
|
+
if (record === null || record === undefined || state.disposed || state.ledgerUnit === null) return Promise.resolve()
|
|
640
|
+
const resident = state.ledgerRecords.get(record.sessionId)
|
|
641
|
+
if (resident !== record && resident?.__durableRecord !== record) return Promise.resolve()
|
|
642
|
+
const waiter = new Promise((resolve) => {
|
|
643
|
+
const waiters = state.ledgerWriteWaiters.get(record.sessionId) || []
|
|
644
|
+
waiters.push(resolve)
|
|
645
|
+
state.ledgerWriteWaiters.set(record.sessionId, waiters)
|
|
646
|
+
})
|
|
647
|
+
state.ledgerPending.set(record.sessionId, record)
|
|
648
|
+
scheduleLedgerWrite()
|
|
649
|
+
return waiter
|
|
650
|
+
}
|
|
651
|
+
async function drainLedgerWrites() {
|
|
652
|
+
if (state.ledgerWriteTimer !== null) {
|
|
653
|
+
clearTimeout(state.ledgerWriteTimer)
|
|
654
|
+
state.ledgerWriteTimer = null
|
|
655
|
+
}
|
|
656
|
+
state.ledgerWriteScheduled = false
|
|
657
|
+
if (state.ledgerPending.size > 0 && !state.ledgerWriteRunning && state.ledgerUnit !== null) startLedgerWrite()
|
|
658
|
+
await state.ledgerWriteChain
|
|
659
|
+
if (state.ledgerPending.size > 0 && !state.ledgerWriteRunning && state.ledgerUnit !== null) {
|
|
660
|
+
startLedgerWrite()
|
|
661
|
+
await state.ledgerWriteChain
|
|
662
|
+
}
|
|
663
|
+
for (const sid of Array.from(state.ledgerWriteWaiters.keys())) {
|
|
664
|
+
if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
function recordCacheLimit() {
|
|
668
|
+
const megabytes = Number(process.env.DSH_ALL_USAGE_RECORD_CACHE_MB)
|
|
669
|
+
return Number.isFinite(megabytes) && megabytes >= 0 ? megabytes * 1024 * 1024 : 16 * 1024 * 1024
|
|
670
|
+
}
|
|
671
|
+
// Rough but allocation-free: the usage rows dominate a record's footprint, and
|
|
672
|
+
// the cap only has to be in the right ballpark.
|
|
673
|
+
function recordSizeOf(record) {
|
|
674
|
+
const usage = Array.isArray(record.usage) ? record.usage.length : 0
|
|
675
|
+
const turns = Array.isArray(record.turns) ? record.turns.length : 0
|
|
676
|
+
return 1024 + usage * 1024 + turns * 256
|
|
677
|
+
}
|
|
678
|
+
function rememberLedgerKey(sid) {
|
|
679
|
+
if (typeof sid === 'string' && sid !== '' && state.ledgerKeys instanceof Set) state.ledgerKeys.add(sid)
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Put a record into the resident set, evicting the oldest clean ones when the
|
|
683
|
+
* cache is over budget. A dirty session (unwritten changes, a pending write or a
|
|
684
|
+
* live resync) is never evicted: its in-memory copy is the pending write.
|
|
685
|
+
*/
|
|
686
|
+
function cacheLedgerRecord(record) {
|
|
687
|
+
if (record === null || record === undefined || typeof record.sessionId !== 'string') return
|
|
688
|
+
const sid = record.sessionId
|
|
689
|
+
if (state.ledgerRecords.has(sid)) {
|
|
690
|
+
state.ledgerRecordBytes = Math.max(0, (state.ledgerRecordBytes || 0) - (state.ledgerRecordSizes.get(sid) || 0))
|
|
691
|
+
state.ledgerRecords.delete(sid)
|
|
692
|
+
}
|
|
693
|
+
const size = recordSizeOf(record)
|
|
694
|
+
state.ledgerRecords.set(sid, record)
|
|
695
|
+
if (!(state.ledgerRecordSizes instanceof Map)) state.ledgerRecordSizes = new Map()
|
|
696
|
+
state.ledgerRecordSizes.set(sid, size)
|
|
697
|
+
state.ledgerRecordBytes = (state.ledgerRecordBytes || 0) + size
|
|
698
|
+
// A store that cannot read one record back has to keep them all.
|
|
699
|
+
if (state.ledgerRecordsComplete === true) return
|
|
700
|
+
const limit = recordCacheLimit()
|
|
701
|
+
while ((state.ledgerRecordBytes || 0) > limit && state.ledgerRecords.size > 1) {
|
|
702
|
+
const oldest = state.ledgerRecords.keys().next()
|
|
703
|
+
if (oldest.done) break
|
|
704
|
+
const candidate = oldest.value
|
|
705
|
+
if (candidate === sid) break
|
|
706
|
+
const protectedDirty = state.ledgerDirtySessions instanceof Set && state.ledgerDirtySessions.has(candidate)
|
|
707
|
+
const protectedPending = state.ledgerPending instanceof Map && state.ledgerPending.has(candidate)
|
|
708
|
+
const protectedWrite = state.ledgerWriting.has(candidate)
|
|
709
|
+
const protectedResync = state.liveResyncPending instanceof Set && state.liveResyncPending.has(candidate)
|
|
710
|
+
const protectedPrefetch = state.ledgerPrefetching instanceof Set && state.ledgerPrefetching.has(candidate)
|
|
711
|
+
const protectedRead = (state.ledgerReading.get(candidate) || 0) > 0
|
|
712
|
+
if (protectedDirty || protectedPending || protectedWrite || protectedResync || protectedPrefetch || protectedRead) {
|
|
713
|
+
// Rotate the protected entry to the back and stop: the resident set is
|
|
714
|
+
// dominated by sessions that are still being written.
|
|
715
|
+
const keep = state.ledgerRecords.get(candidate)
|
|
716
|
+
state.ledgerRecords.delete(candidate)
|
|
717
|
+
state.ledgerRecords.set(candidate, keep)
|
|
718
|
+
break
|
|
719
|
+
}
|
|
720
|
+
state.ledgerRecords.delete(candidate)
|
|
721
|
+
state.ledgerRecordBytes = Math.max(0, (state.ledgerRecordBytes || 0) - (state.ledgerRecordSizes.get(candidate) || 0))
|
|
722
|
+
state.ledgerRecordSizes.delete(candidate)
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Read a window of session documents, a few files at a time. One file per read is
|
|
727
|
+
* what keeps memory flat, but doing that strictly one-at-a-time serialises the
|
|
728
|
+
* I/O behind the folding; a small parallel window restores the overlap while the
|
|
729
|
+
* resident set stays bounded (a record is dropped again once it is folded).
|
|
730
|
+
*/
|
|
731
|
+
async function loadRecords(sids, concurrency = 16) {
|
|
732
|
+
if (state.ledgerUnit === null || typeof state.ledgerUnit.readRecord !== 'function') return
|
|
733
|
+
if (!(state.ledgerKeys instanceof Set)) return
|
|
734
|
+
const queue = []
|
|
735
|
+
for (const sid of sids) {
|
|
736
|
+
if (typeof sid !== 'string' || sid === '') continue
|
|
737
|
+
if (state.ledgerRecords.has(sid) || !state.ledgerKeys.has(sid)) continue
|
|
738
|
+
queue.push(sid)
|
|
739
|
+
}
|
|
740
|
+
if (!(state.ledgerPrefetching instanceof Set)) state.ledgerPrefetching = new Set()
|
|
741
|
+
const width = Number.isFinite(concurrency) && concurrency > 0 ? Math.floor(concurrency) : 8
|
|
742
|
+
for (let index = 0; index < queue.length; index += width) {
|
|
743
|
+
const batch = queue.slice(index, index + width)
|
|
744
|
+
await Promise.all(batch.map((sid) => {
|
|
745
|
+
state.ledgerPrefetching.add(sid)
|
|
746
|
+
return recordFor(sid).catch(() => undefined).finally(() => state.ledgerPrefetching.delete(sid))
|
|
747
|
+
}))
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Read back the detail rows of every session that has usage in a date range, so a
|
|
752
|
+
* query over old history sees them without the rows having to stay resident. The
|
|
753
|
+
* rows are adopted, not folded: the cubes already hold their contribution.
|
|
754
|
+
*/
|
|
755
|
+
/** A row read back from a record that no longer belongs to a live workspace is
|
|
756
|
+
* attributed to the shared retired bucket, exactly like the scan does. */
|
|
757
|
+
function adoptableRow(row) {
|
|
758
|
+
const published = typeof row.wsId === 'string' && row.wsId !== '' && state.wsMeta instanceof Map && state.wsMeta.has(row.wsId)
|
|
759
|
+
const digest = state.perSession?.get(row.sid)
|
|
760
|
+
const keyMeta = digest && digest.keys instanceof Map ? digest.keys.get(row.key) : undefined
|
|
761
|
+
const keyWorkspace = keyMeta && typeof keyMeta.wsId === 'string' && keyMeta.wsId !== '' ? keyMeta.wsId : undefined
|
|
762
|
+
if (keyWorkspace !== undefined) {
|
|
763
|
+
const keyWorkspacePublished = keyWorkspace === RETIRED_WORKSPACE_ID || (state.wsMeta instanceof Map && state.wsMeta.has(keyWorkspace))
|
|
764
|
+
if (keyWorkspace !== row.wsId && keyWorkspacePublished) return { ...row, wsId: keyWorkspace, ...(keyWorkspace === RETIRED_WORKSPACE_ID ? { retiredAtRead: true } : {}) }
|
|
765
|
+
if (keyWorkspace === row.wsId && keyWorkspacePublished) return row
|
|
766
|
+
}
|
|
767
|
+
// Legacy digests do not carry workspace ownership per key. Preserve the prior
|
|
768
|
+
// all-session fallback only for those; mixed digests are ambiguous by design.
|
|
769
|
+
const moved = keyWorkspace === undefined && published && state.retiringWorkspaceIds?.has(row.wsId) && digest !== undefined && digest.buckets.size > 0 && Array.from(digest.buckets.values()).every(bucket => bucket.wsId === RETIRED_WORKSPACE_ID)
|
|
770
|
+
return (published && !moved) || row.wsId === RETIRED_WORKSPACE_ID ? row : { ...row, wsId: RETIRED_WORKSPACE_ID, retiredAtRead: true }
|
|
771
|
+
}
|
|
772
|
+
/** Read back every detail row of one session (used before a retarget walks them). */
|
|
773
|
+
async function loadRowsForSession(sid) {
|
|
774
|
+
const record = await recordFor(sid)
|
|
775
|
+
if (record === undefined || record === null || !Array.isArray(record.usage)) return 0
|
|
776
|
+
let adopted = 0
|
|
777
|
+
for (const item of record.usage) if (adoptUsageRow(adoptableRow(usageRowOf(record, item, 'ledger-reuse')))) adopted += 1
|
|
778
|
+
return adopted
|
|
779
|
+
}
|
|
780
|
+
// An authoritative usage array owns this lookup weakly. Values contain only
|
|
781
|
+
// bounded locators and numeric positions, never a row/array/cost reference.
|
|
782
|
+
// Replacement and LRU eviction can therefore release the old payloads.
|
|
783
|
+
const usagePageIndices = new WeakMap()
|
|
784
|
+
function storedRowsForPage(record, selected) {
|
|
785
|
+
const requests = [
|
|
786
|
+
...Array.from(selected.keys, ([key, positions]) => ({ key, positions, lookup: recordKeyLocator(record.sessionId, key) })),
|
|
787
|
+
...Array.from(selected.hashes, ([keyHash, positions]) => ({ keyHash, positions, lookup: 'hash:' + keyHash })),
|
|
788
|
+
]
|
|
789
|
+
const build = () => {
|
|
790
|
+
const index = { length: record.usage.length, positions: new Map() }
|
|
791
|
+
for (let position = 0; position < record.usage.length; position += 1) {
|
|
792
|
+
const stored = record.usage[position]
|
|
793
|
+
if (typeof stored?.key === 'string') index.positions.set(recordKeyLocator(record.sessionId, stored.key), position)
|
|
794
|
+
}
|
|
795
|
+
usagePageIndices.set(record.usage, index)
|
|
796
|
+
return index
|
|
797
|
+
}
|
|
798
|
+
let index = usagePageIndices.get(record.usage)
|
|
799
|
+
const fresh = index === undefined || index.length !== record.usage.length
|
|
800
|
+
if (fresh) index = build()
|
|
801
|
+
const matches = (stored, request) => stored !== undefined && (request.key === undefined ? recordKeyHash(stored.key) === request.keyHash : stored.key === request.key)
|
|
802
|
+
const resolve = () => requests.map(request => ({ request, stored: record.usage[index.positions.get(request.lookup)] }))
|
|
803
|
+
let rows = resolve()
|
|
804
|
+
// Same-length in-place rewrites/reorders can invalidate numeric positions.
|
|
805
|
+
// Verify selected keys and rebuild once before declaring a key missing.
|
|
806
|
+
if (!fresh && rows.some(({ stored, request }) => !matches(stored, request))) { index = build(); rows = resolve() }
|
|
807
|
+
return rows.filter(({ stored, request }) => matches(stored, request)).map(({ stored, request }) => [stored, request.positions])
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// A compact result keeps only keys. Borrow just the sessions needed by this
|
|
811
|
+
// page, without adopting their unrelated rows back into the resident set.
|
|
812
|
+
async function readUsageRows(references, utc = false) {
|
|
813
|
+
const rows = new Array(references.length).fill(undefined)
|
|
814
|
+
const sessions = new Map()
|
|
815
|
+
const assign = (positions, item) => {
|
|
816
|
+
for (const index of positions) rows[index] = { item, date: utc ? item.dateUtc : item.date }
|
|
817
|
+
}
|
|
818
|
+
for (let index = 0; index < references.length; index += 1) {
|
|
819
|
+
const { sid, key, keyHash } = references[index]
|
|
820
|
+
const item = key === undefined ? undefined : state.usageByStep.get(key)
|
|
821
|
+
if (item !== undefined && item.sid === sid) { assign([index], item); continue }
|
|
822
|
+
let selected = sessions.get(sid)
|
|
823
|
+
if (selected === undefined) { selected = { keys: new Map(), hashes: new Map() }; sessions.set(sid, selected) }
|
|
824
|
+
const wanted = key === undefined ? selected.hashes : selected.keys
|
|
825
|
+
const locator = key === undefined ? keyHash : key
|
|
826
|
+
let positions = wanted.get(locator)
|
|
827
|
+
if (positions === undefined) { positions = []; wanted.set(locator, positions) }
|
|
828
|
+
positions.push(index)
|
|
829
|
+
}
|
|
830
|
+
// Hash lookups are only needed for noncanonical legacy keys. Ordinary pages
|
|
831
|
+
// use direct resident lookups and read each missing session at most once.
|
|
832
|
+
if (Array.from(sessions.values()).some(selected => selected.hashes.size > 0)) {
|
|
833
|
+
for (const item of state.usageByStep.values()) {
|
|
834
|
+
const selected = sessions.get(item.sid)
|
|
835
|
+
if (selected === undefined || selected.hashes.size === 0) continue
|
|
836
|
+
const hash = recordKeyHash(item.key)
|
|
837
|
+
const positions = selected.hashes.get(hash)
|
|
838
|
+
if (positions !== undefined) { assign(positions, item); selected.hashes.delete(hash) }
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const queue = Array.from(sessions).filter(([, selected]) => selected.keys.size > 0 || selected.hashes.size > 0)
|
|
842
|
+
for (let offset = 0; offset < queue.length; offset += 8) {
|
|
843
|
+
const results = await Promise.allSettled(queue.slice(offset, offset + 8).map(async ([sid, selected]) => {
|
|
844
|
+
const releaseRecord = pinRecord(sid)
|
|
845
|
+
try {
|
|
846
|
+
const record = await recordFor(sid)
|
|
847
|
+
if (record === undefined || record === null || !Array.isArray(record.usage)) return
|
|
848
|
+
for (const [stored, positions] of storedRowsForPage(record, selected)) {
|
|
849
|
+
const resident = state.usageByStep.get(stored.key)
|
|
850
|
+
const item = resident !== undefined && resident.sid === sid ? resident : adoptableRow(usageRowOf(record, stored, 'ledger-reuse'))
|
|
851
|
+
assign(positions, item)
|
|
852
|
+
}
|
|
853
|
+
} finally { releaseRecord() }
|
|
854
|
+
}))
|
|
855
|
+
const failed = results.find(result => result.status === 'rejected')
|
|
856
|
+
if (failed !== undefined) throw failed.reason
|
|
857
|
+
}
|
|
858
|
+
return rows
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
async function loadRowsForRange(utc, start, end) {
|
|
863
|
+
if (typeof sessionsForRange !== 'function' || typeof adoptUsageRow !== 'function') return 0
|
|
864
|
+
const sessions = sessionsForRange(utc === true, start, end)
|
|
865
|
+
if (sessions.size === 0) return 0
|
|
866
|
+
let adopted = 0
|
|
867
|
+
// A day can own rows in dozens of sessions; reading their documents one at a
|
|
868
|
+
// time made a first open of an old day take hundreds of milliseconds, so the
|
|
869
|
+
// reads go in small parallel windows (the row is data, the order does not matter).
|
|
870
|
+
const queue = Array.from(sessions)
|
|
871
|
+
for (let index = 0; index < queue.length; index += 8) {
|
|
872
|
+
const batch = queue.slice(index, index + 8)
|
|
873
|
+
await Promise.all(batch.map(async (sid) => {
|
|
874
|
+
const record = await recordFor(sid)
|
|
875
|
+
if (record === undefined || record === null || !Array.isArray(record.usage)) return
|
|
876
|
+
for (const item of record.usage) {
|
|
877
|
+
const row = adoptableRow(usageRowOf(record, item, 'ledger-reuse'))
|
|
878
|
+
const date = utc === true ? row.dateUtc : row.date
|
|
879
|
+
if (typeof start === 'string' && start !== '' && date < start) continue
|
|
880
|
+
if (typeof end === 'string' && end !== '' && date > end) continue
|
|
881
|
+
if (adoptUsageRow(row)) adopted += 1
|
|
882
|
+
}
|
|
883
|
+
}))
|
|
884
|
+
}
|
|
885
|
+
return adopted
|
|
886
|
+
}
|
|
887
|
+
/** The record of one session: resident, else read from its own document. */
|
|
888
|
+
// A caller that borrows a record across awaits must keep the authoritative
|
|
889
|
+
// resident copy alive until its work (including any write-back) has settled.
|
|
890
|
+
function pinRecord(sid) {
|
|
891
|
+
state.ledgerReading.set(sid, (state.ledgerReading.get(sid) || 0) + 1)
|
|
892
|
+
let released = false
|
|
893
|
+
return () => {
|
|
894
|
+
if (released) return
|
|
895
|
+
released = true
|
|
896
|
+
const remaining = (state.ledgerReading.get(sid) || 1) - 1
|
|
897
|
+
if (remaining > 0) state.ledgerReading.set(sid, remaining)
|
|
898
|
+
else state.ledgerReading.delete(sid)
|
|
899
|
+
const current = state.ledgerRecords.get(sid)
|
|
900
|
+
if (!state.disposed && current !== undefined) cacheLedgerRecord(current)
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
async function recordFor(sid) {
|
|
904
|
+
if (typeof sid !== 'string' || sid === '') return undefined
|
|
905
|
+
const resident = state.ledgerRecords.get(sid)
|
|
906
|
+
if (resident !== undefined) {
|
|
907
|
+
cacheLedgerRecord(resident)
|
|
908
|
+
return resident
|
|
909
|
+
}
|
|
910
|
+
if (state.ledgerKeys instanceof Set && !state.ledgerKeys.has(sid)) return undefined
|
|
911
|
+
if (state.ledgerUnit === null || typeof state.ledgerUnit.readRecord !== 'function') return undefined
|
|
912
|
+
// Keep a concurrently installed authoritative record resident until every
|
|
913
|
+
// older disk read of this session has settled. Otherwise LRU pressure can
|
|
914
|
+
// remove the evidence that the old read must not restore deleted history.
|
|
915
|
+
const releaseRecord = pinRecord(sid)
|
|
916
|
+
try {
|
|
917
|
+
const raw = await state.ledgerUnit.readRecord(sid)
|
|
918
|
+
const current = state.ledgerRecords.get(sid)
|
|
919
|
+
if (current !== undefined) return current
|
|
920
|
+
if (raw === undefined) return undefined
|
|
921
|
+
const record = normalizeLedgerRecord(raw, sid)
|
|
922
|
+
if (record === null) {
|
|
923
|
+
console.warn('[all-usage] ignoring malformed usage ledger row:', sid)
|
|
924
|
+
return undefined
|
|
925
|
+
}
|
|
926
|
+
if (!state.disposed) cacheLedgerRecord(record)
|
|
927
|
+
return record
|
|
928
|
+
} catch (err) {
|
|
929
|
+
const current = state.ledgerRecords.get(sid)
|
|
930
|
+
if (current !== undefined) return current
|
|
931
|
+
throw err
|
|
932
|
+
} finally {
|
|
933
|
+
releaseRecord()
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
async function loadLedger() {
|
|
937
|
+
if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
|
|
938
|
+
try {
|
|
939
|
+
const backend = storage.backend.get('json')
|
|
940
|
+
if (backend === undefined || backend === null || backend.kv === undefined) return
|
|
941
|
+
const opened = await openLedgerUnitStore(backend)
|
|
942
|
+
if (state.disposed) { await opened.store.close(); return }
|
|
943
|
+
state.ledgerUnit = opened.store
|
|
944
|
+
if (!(state.ledgerKeys instanceof Set)) state.ledgerKeys = new Set()
|
|
945
|
+
if (!(state.ledgerRecordSizes instanceof Map)) state.ledgerRecordSizes = new Map()
|
|
946
|
+
if (!Number.isFinite(state.ledgerRecordBytes)) state.ledgerRecordBytes = 0
|
|
947
|
+
// The per-record tree is its own index: enumerate the keys and read a record
|
|
948
|
+
// only when something asks for it, so a cold start no longer materialises
|
|
949
|
+
// every session document at once (123 MB on a 66k-row ledger).
|
|
950
|
+
if (opened.streaming !== true) {
|
|
951
|
+
// A unit that cannot read a single record has already handed us the whole
|
|
952
|
+
// table; keep the old behaviour there (tests, exotic backends).
|
|
953
|
+
state.ledgerRecordsComplete = true
|
|
954
|
+
for (const [key, raw] of Object.entries(opened.rows)) {
|
|
955
|
+
if (key.startsWith('__')) continue
|
|
956
|
+
const record = normalizeLedgerRecord(raw, key)
|
|
957
|
+
if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
|
|
958
|
+
else cacheLedgerRecord(record)
|
|
959
|
+
state.ledgerKeys.add(key)
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (typeof opened.store.listKeys === 'function') {
|
|
963
|
+
for (const key of await opened.store.listKeys()) state.ledgerKeys.add(key)
|
|
964
|
+
}
|
|
965
|
+
if (state.ledgerUnit !== null && state.ledgerUnit !== undefined && typeof state.ledgerUnit.readRecord !== 'function') state.ledgerRecordsComplete = true
|
|
966
|
+
} catch (err) {
|
|
967
|
+
console.error('[all-usage] usage ledger unavailable:', err)
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return {
|
|
972
|
+
nextLedgerRevision,
|
|
973
|
+
ledgerEventKey,
|
|
974
|
+
sessionEvents,
|
|
975
|
+
retargetLedgerRecord,
|
|
976
|
+
buildLedgerRecord,
|
|
977
|
+
normalizeLedgerRecord,
|
|
978
|
+
prepareLedgerRecord,
|
|
979
|
+
applyLedgerRecord,
|
|
980
|
+
ledgerRank,
|
|
981
|
+
replaceLedgerRecord,
|
|
982
|
+
storeLedgerRecord,
|
|
983
|
+
persistLedgerRecord,
|
|
984
|
+
drainLedgerWrites,
|
|
985
|
+
// Exposed for the round-trip test: the on-disk shape is private otherwise.
|
|
986
|
+
serializeLedgerRecordForTest: serializeLedgerRecord,
|
|
987
|
+
expandLedgerRecordForTest: expandLedgerRecord,
|
|
988
|
+
assertLedgerRecord,
|
|
989
|
+
loadLedger,
|
|
990
|
+
recordFor,
|
|
991
|
+
pinRecord,
|
|
992
|
+
loadRecords,
|
|
993
|
+
adoptableRow,
|
|
994
|
+
loadRowsForRange,
|
|
995
|
+
readUsageRows,
|
|
996
|
+
loadRowsForSession
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
export { LEDGER_VERSION, PREVIOUS_LEDGER_VERSION, LEGACY_LEDGER_VERSION, LEDGER_SHARD_COUNT, RETIRED_WORKSPACE_ID, ledgerShardIndex, retargetLedgerRecord, serializeLedgerRecord }
|