dsh-all-usage 1.2.2 → 1.2.4

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.de.md +316 -0
  3. package/README.en.md +52 -14
  4. package/README.es.md +316 -0
  5. package/README.fr.md +316 -0
  6. package/README.ja.md +316 -0
  7. package/README.md +55 -17
  8. package/README.pt.md +316 -0
  9. package/README.ru.md +316 -0
  10. package/assets/cover-en.png +0 -0
  11. package/assets/cover-zh.png +0 -0
  12. package/assets/readme-architecture-en.svg +95 -95
  13. package/assets/readme-architecture-zh.svg +95 -95
  14. package/assets/readme-sync-en.svg +81 -81
  15. package/assets/readme-sync-zh.svg +81 -81
  16. package/assets/screenshots/en/01-overview.png +0 -0
  17. package/assets/screenshots/en/02-trend-bars.png +0 -0
  18. package/assets/screenshots/en/02-trend.png +0 -0
  19. package/assets/screenshots/en/03-heatmap.png +0 -0
  20. package/assets/screenshots/en/04-model-stats.png +0 -0
  21. package/assets/screenshots/en/05-request-logs.png +0 -0
  22. package/assets/screenshots/en/06-cost-settings.png +0 -0
  23. package/assets/screenshots/en/07-maintenance.png +0 -0
  24. package/assets/screenshots/zh/01-overview.png +0 -0
  25. package/assets/screenshots/zh/02-trend-bars.png +0 -0
  26. package/assets/screenshots/zh/02-trend.png +0 -0
  27. package/assets/screenshots/zh/03-heatmap.png +0 -0
  28. package/assets/screenshots/zh/04-model-stats.png +0 -0
  29. package/assets/screenshots/zh/05-request-logs.png +0 -0
  30. package/assets/screenshots/zh/06-cost-settings.png +0 -0
  31. package/assets/screenshots/zh/07-maintenance.png +0 -0
  32. package/lib/archive.js +399 -0
  33. package/lib/client.js +1 -1
  34. package/lib/http.js +27 -1
  35. package/lib/ledger.js +362 -54
  36. package/package.json +10 -1
  37. package/screenshots.json +7 -9
  38. package/scripts/archive-ledger.mjs +156 -0
  39. package/assets/screenshots/en/04-model-usage.png +0 -0
  40. package/assets/screenshots/zh/04-model-usage.png +0 -0
package/lib/http.js CHANGED
@@ -159,7 +159,7 @@ export function registerRoutes(host) {
159
159
  const { runBaseline } = host.sessionSync
160
160
  const { fetchBalance } = host.balance
161
161
  const { setAlias } = host.aliases
162
- const { drainLedgerWrites } = host.ledger
162
+ const { drainLedgerWrites, archiveLedger } = host.ledger
163
163
 
164
164
  // ---------- HTTP data routes for the client half ----------
165
165
  if (webServer !== undefined) {
@@ -275,6 +275,32 @@ export function registerRoutes(host) {
275
275
  sendJson(res, result.ok ? 200 : 502, result)
276
276
  },
277
277
  }))
278
+ ctx.effect(() => webServer.register({
279
+ kind: 'exact',
280
+ path: '/api/all-usage/archive',
281
+ // The only route that deletes ledger documents, so it takes the same write
282
+ // capability as the pricing writes and a body it does not otherwise need.
283
+ handler: async (req, res) => {
284
+ if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
285
+ if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
286
+ const body = await readBody(req, 8 * 1024)
287
+ if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
288
+ let args = null
289
+ try { args = JSON.parse(body.text) } catch (err) { /* invalid json */ }
290
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) { sendJson(res, 400, { ok: false, message: 'invalid-json' }); return }
291
+ const days = args.days === undefined ? 90 : Number(args.days)
292
+ if (!Number.isInteger(days) || days < 1 || days > 3650) { sendJson(res, 400, { ok: false, message: 'invalid-days' }); return }
293
+ // Only an explicit false writes: the panel's first request has to show the
294
+ // plan, and a client that forgets the flag must not delete anything.
295
+ const dryRun = args.dryRun !== false
296
+ await state.ledgerReady
297
+ const result = await archiveLedger({ days, dryRun })
298
+ // The body is the ledger's own answer, so the panel can show the plan (or
299
+ // the reason there is none) without a second shape to keep in sync.
300
+ const status = result.ok === true ? 200 : ({ busy: 409, 'no-root': 400, 'ledger-unavailable': 503 })[result.message] ?? 500
301
+ sendJson(res, status, result)
302
+ },
303
+ }))
278
304
  ctx.effect(() => webServer.register({
279
305
  kind: 'exact',
280
306
  path: '/api/all-usage/status',
package/lib/ledger.js CHANGED
@@ -1,5 +1,7 @@
1
- import { readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
1
+ import { mkdir, readdir, readFile, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
2
+ import { hostname } from 'node:os'
2
3
  import { join } from 'node:path'
4
+ import { archiveRecords, createArchiveReader } from './archive.js'
3
5
  import { recordKeyHash, recordKeyLocator } from './records-cache.js'
4
6
  import { calculateCost, normalizeCostSnapshot } from './pricing.js'
5
7
  import { billingInstantOf, contextTimeKey, extractUsageEvent, normalizeUsageValues as usageValues, pickPricingTime, touchContextTimes, upsertUsageSample as upsertUsageSampleState, usageStepKey } from './usage-core.js'
@@ -38,6 +40,13 @@ const LEDGER_SHARD_COUNT = 32
38
40
  const LEDGER_UNIT_NAME = 'all_usage_ledger'
39
41
  const LEDGER_META_KEY = '__all_usage_ledger_meta__'
40
42
  const LEDGER_WRITE_DEBOUNCE_MS = 25
43
+ // A second DSH process can share this data root, and maintenance deletes files
44
+ // the other one may still be reading. One lock guards every step that does: the
45
+ // first migration and every retirement. Record writes never take it.
46
+ const LEDGER_LOCK_OWNER_FILE = 'owner.json'
47
+ // A holder silent for this long is treated as killed. The threshold has to exceed
48
+ // the slowest honest migration, or a slow copy would be reclaimed while it runs.
49
+ const LEDGER_LOCK_STALE_MS = 30000
41
50
 
42
51
  // DSH 0.1.5 replaced the Session `events` getter with `snapshotEvents()` while
43
52
  // 0.1.1 exposed `events`; both return the log in seq order. Read whichever the
@@ -101,11 +110,189 @@ function uniqueUnits(units) {
101
110
  return Array.from(new Set(units.filter((unit) => unit !== null && unit !== undefined)))
102
111
  }
103
112
 
104
- function createLedgerUnitStore(legacyUnit, shardUnits, root = '') {
113
+ // The maintenance lock is a directory: mkdir is atomic, so exactly one process can
114
+ // create it, and the owner document inside tells later readers who holds it and
115
+ // since when. It is never taken around a record write.
116
+ function ledgerMaintenanceLockDir(root) {
117
+ return join(root, LEDGER_UNIT_NAME + '.lock')
118
+ }
119
+
120
+ async function ledgerMaintenanceLockIsStale(dir, ownerFile) {
121
+ let startedAt = null
122
+ try {
123
+ const owner = JSON.parse(await readFile(ownerFile, 'utf8'))
124
+ if (owner !== null && typeof owner === 'object' && Number.isFinite(owner.startedAt)) startedAt = owner.startedAt
125
+ } catch (error) {
126
+ startedAt = null
127
+ }
128
+ if (startedAt !== null) return Date.now() - startedAt > LEDGER_LOCK_STALE_MS
129
+ // The owner document is written right after the directory is created, so a lock
130
+ // without one is either mid-acquisition or half-removed. Only the age of the
131
+ // directory tells those apart from a holder that never got to write it.
132
+ const info = await stat(dir).catch(() => null)
133
+ return info === null || Date.now() - info.mtimeMs > LEDGER_LOCK_STALE_MS
134
+ }
135
+
136
+ /**
137
+ * Take the maintenance lock for one root. Returns a handle, or null: null means
138
+ * another process holds the lock, or something unexpected happened, and either way
139
+ * the caller must not run maintenance.
140
+ */
141
+ async function acquireMaintenanceLock(root) {
142
+ if (typeof root !== 'string' || root === '') return null
143
+ const dir = ledgerMaintenanceLockDir(root)
144
+ const ownerFile = join(dir, LEDGER_LOCK_OWNER_FILE)
145
+ for (let attempt = 0; attempt < 2; attempt += 1) {
146
+ try {
147
+ await mkdir(dir, { mode: 0o700 })
148
+ // No reader could ever date a lock without an owner document, so a failed
149
+ // write releases the directory again instead of leaving it behind.
150
+ const owner = { pid: process.pid, startedAt: Date.now(), host: hostname() }
151
+ try {
152
+ await writeFile(ownerFile, JSON.stringify(owner), { encoding: 'utf8', mode: 0o600 })
153
+ } catch (error) {
154
+ await rmdir(dir).catch(() => {})
155
+ return null
156
+ }
157
+ return { dir, ownerFile, owner }
158
+ } catch (error) {
159
+ if (error === null || typeof error !== 'object' || error.code !== 'EEXIST') return null
160
+ if (attempt > 0 || !(await ledgerMaintenanceLockIsStale(dir, ownerFile))) return null
161
+ // Reclaim one lock left behind by a killed holder: its owner document and its
162
+ // now empty directory, never anything else.
163
+ await unlink(ownerFile).catch(() => {})
164
+ if (!(await rmdir(dir).then(() => true, () => false))) return null
165
+ }
166
+ }
167
+ return null
168
+ }
169
+
170
+ async function releaseMaintenanceLock(handle) {
171
+ if (handle === null || handle === undefined) return
172
+ const { dir, ownerFile, owner } = handle
173
+ try {
174
+ // A lock another process reclaimed belongs to that process now, and removing
175
+ // its owner document would let a third one in while it works.
176
+ const current = JSON.parse(await readFile(ownerFile, 'utf8'))
177
+ if (current === null || typeof current !== 'object' || current.pid !== owner.pid || current.startedAt !== owner.startedAt) return
178
+ } catch (error) {
179
+ // A missing owner document leaves nothing of this handle to remove.
180
+ return
181
+ }
182
+ await unlink(ownerFile).catch(() => {})
183
+ await rmdir(dir).catch(() => {})
184
+ }
185
+
186
+ /**
187
+ * Run fn while this process holds the maintenance lock of one root, and hand it the
188
+ * lock handle so the step can pass it on instead of taking a second one. Returns
189
+ * { acquired: true, value } when fn ran, or { acquired: false } when another process
190
+ * owns the lock (or the root has none to take), in which case fn was never called:
191
+ * a maintenance step that cannot prove it is alone must not delete or rewrite
192
+ * anything. The migration and the archive command both go through here, so every
193
+ * path that removes ledger files shares one lock and one release.
194
+ */
195
+ async function withLedgerMaintenanceLock(root, fn) {
196
+ const lock = await acquireMaintenanceLock(root)
197
+ if (lock === null) return { acquired: false }
198
+ try {
199
+ return { acquired: true, value: await fn(lock) }
200
+ } finally {
201
+ await releaseMaintenanceLock(lock)
202
+ }
203
+ }
204
+
205
+ // legacyFallbackRows carries the rows of a legacy whole-unit document that the
206
+ // trees have not received yet, so read-only maintenance can still answer for them.
207
+ // archive is the cold half of the ledger (lib/archive.js): its session ids join
208
+ // listKeys() and its rows answer a read the trees cannot, never the other way round.
209
+ function createLedgerUnitStore(legacyUnit, shardUnits, root = '', legacyFallbackRows = undefined, archive = null) {
105
210
  const shards = uniqueUnits(shardUnits)
106
211
  const allUnits = uniqueUnits([legacyUnit, ...shards])
107
- return {
108
-
212
+ // Mutable on purpose: a live archive run removes the tree documents it just
213
+ // filed, so the reader built at start stops answering for exactly those records.
214
+ // attachArchive() swaps in the reader of the month files that run wrote.
215
+ let archiveReader = archive
216
+ // Every session key the trees hold, without parsing a single record: the
217
+ // per-record layout is a directory of documents, so the filenames are the index
218
+ // and a record is read later, on demand, with readRecord().
219
+ async function listTreeKeys() {
220
+ const keys = []
221
+ const known = new Set()
222
+ for (const unit of shards) {
223
+ let dir = ''
224
+ try {
225
+ dir = unit.tableDir('sessions')
226
+ } catch (err) {
227
+ continue
228
+ }
229
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
230
+ for (const entry of entries) {
231
+ if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name.startsWith('__')) continue
232
+ const key = entry.name.slice(0, -'.json'.length)
233
+ if (key === LEDGER_META_KEY || known.has(key)) continue
234
+ known.add(key)
235
+ keys.push(key)
236
+ }
237
+ }
238
+ return keys
239
+ }
240
+ // One document, parsed the way the storage unit parses it when it loads. A tree
241
+ // that does not answer for a record yet is the archive's business.
242
+ async function readTreeRecord(key) {
243
+ const unit = shardUnits[ledgerShardIndex(key)]
244
+ if (unit === undefined) return undefined
245
+ let path = ''
246
+ try {
247
+ path = join(unit.tableDir('sessions'), key + '.json')
248
+ } catch (err) {
249
+ return undefined
250
+ }
251
+ try {
252
+ const doc = JSON.parse(await readFile(path, 'utf8'))
253
+ if (doc !== null && typeof doc === 'object' && doc.version === 0 && doc.record !== undefined) return doc.record
254
+ } catch (err) {
255
+ // A missing or unreadable document is the archive's and the legacy
256
+ // fallback's business.
257
+ }
258
+ return undefined
259
+ }
260
+ /**
261
+ * Remove one tree document, which is what archiving does once a record's month
262
+ * file, key index and the manifest are all on disk. Best effort by contract: a
263
+ * document left behind is a duplicate the read path already prefers, never a
264
+ * lost record.
265
+ *
266
+ * expected is the compact row that was archived. Runtime writes do not hold the
267
+ * maintenance lock, so between planning a key and deleting it the session may
268
+ * have published a newer revision; the document is then re-read and left alone
269
+ * unless it still holds exactly that row, because deleting it would throw away
270
+ * a revision nothing else has a copy of yet.
271
+ */
272
+ async function deleteTreeRecord(key, expected) {
273
+ const unit = shardUnits[ledgerShardIndex(key)]
274
+ if (unit === undefined) return false
275
+ let path = ''
276
+ try {
277
+ path = join(unit.tableDir('sessions'), key + '.json')
278
+ } catch (err) {
279
+ return false
280
+ }
281
+ if (expected !== undefined) {
282
+ if (expected === null || typeof expected !== 'object') return false
283
+ const text = await readFile(path, 'utf8').catch(() => null)
284
+ if (text === null) return false
285
+ let doc = null
286
+ try { doc = JSON.parse(text) } catch (err) { return false }
287
+ if (doc === null || typeof doc !== 'object' || doc.record === null || typeof doc.record !== 'object') return false
288
+ // updatedAt is the revision stamp; the serialized row is the belt and braces
289
+ // that keeps a same-stamp rewrite in the tree too.
290
+ if (doc.record.updatedAt !== expected.updatedAt) return false
291
+ if (JSON.stringify(doc.record) !== JSON.stringify(expected)) return false
292
+ }
293
+ return unlink(path).then(() => true, () => false)
294
+ }
295
+ const store = {
109
296
  async loadAll() {
110
297
  const snapshots = await Promise.all(shards.map((unit) => unit.loadAll()))
111
298
  const rows = Object.create(null)
@@ -122,39 +309,58 @@ function createLedgerUnitStore(legacyUnit, shardUnits, root = '') {
122
309
  * index and a record is read later, on demand, with readRecord().
123
310
  */
124
311
  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
- }
312
+ const keys = await listTreeKeys()
313
+ const known = new Set(keys)
314
+ const remember = (key) => {
315
+ if (key === LEDGER_META_KEY || known.has(key)) return
316
+ known.add(key)
317
+ keys.push(key)
138
318
  }
319
+ // A legacy row that a concurrent migration has not copied into the trees
320
+ // yet is still a record of this ledger.
321
+ if (legacyFallbackRows !== undefined) for (const key of Object.keys(legacyFallbackRows)) remember(key)
322
+ // Cold records are records of this ledger too, and their ids are what lets
323
+ // recordFor() know that reading one is worth a month decompression.
324
+ if (archiveReader !== null) for (const key of archiveReader.keys()) remember(key)
139
325
  return keys
140
326
  },
141
- /** One document, parsed the way the storage unit parses it when it loads. */
327
+ /**
328
+ * One document, parsed the way the storage unit parses it when it loads. A tree
329
+ * that does not answer for a record yet falls back to the cold archive and then
330
+ * to the legacy rows that read-only maintenance keeps in memory.
331
+ */
142
332
  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
333
+ // The tree is the newer copy whenever it holds one, so a record that is both
334
+ // archived and still in its tree is read once, from the tree: a failed
335
+ // archive run leaves duplicates that must not be counted twice.
336
+ const tree = await readTreeRecord(key)
337
+ if (tree !== undefined) return tree
338
+ if (archiveReader !== null) {
339
+ const archived = await archiveReader.readRecord(key).catch(() => undefined)
340
+ if (archived !== undefined) return archived
157
341
  }
342
+ if (legacyFallbackRows === undefined || !Object.hasOwn(legacyFallbackRows, key)) return undefined
343
+ return legacyFallbackRows[key]
344
+ },
345
+ /** See deleteTreeRecord(): archiving is the only caller that removes documents. */
346
+ deleteRecord: deleteTreeRecord,
347
+ /**
348
+ * Swap the cold reader, or drop it with null. A run that just deleted tree
349
+ * documents must re-attach the reader of the month files it wrote, or every read
350
+ * of a record it filed would answer 'missing' until the next DSH start.
351
+ */
352
+ attachArchive(reader) {
353
+ archiveReader = reader === undefined ? null : reader
354
+ return store
355
+ },
356
+ /**
357
+ * The trees alone: no archive ids, no legacy rows. archiveRecords() must get this
358
+ * view rather than the store, because the store answers from the month files it
359
+ * is about to merge into - it would plan every already-archived record again and
360
+ * count it a second time.
361
+ */
362
+ treeStore() {
363
+ return { listKeys: listTreeKeys, readRecord: readTreeRecord, deleteRecord: deleteTreeRecord }
158
364
  },
159
365
  async hasMigrationMarker() {
160
366
  if (shardUnits[0] === undefined) return false
@@ -172,6 +378,19 @@ function createLedgerUnitStore(legacyUnit, shardUnits, root = '') {
172
378
  },
173
379
  shardCount: LEDGER_SHARD_COUNT,
174
380
  }
381
+ return store
382
+ }
383
+
384
+ // The archive is optional and never fatal: a root that cannot provide a readable
385
+ // archive simply opens as a tree-only ledger (lib/archive.js already treats a
386
+ // damaged month as missing), and the error is reported once at start.
387
+ async function openLedgerArchive(root) {
388
+ try {
389
+ return await createArchiveReader(root)
390
+ } catch (error) {
391
+ console.warn('[all-usage] usage ledger archive is unavailable:', error && error.message)
392
+ return null
393
+ }
175
394
  }
176
395
 
177
396
  async function openLedgerUnitStore(backend) {
@@ -186,7 +405,11 @@ async function openLedgerUnitStore(backend) {
186
405
  shardUnits.push(await backend.kv.open({ name: ledgerShardName(index), version: 0, tables: ['sessions'], hasGlobal: false, layout: 'per-record' }))
187
406
  }
188
407
  const root = typeof backend.root === 'string' ? backend.root : ''
189
- const shardStore = createLedgerUnitStore(undefined, shardUnits, root)
408
+ // Cold records live in <root>/archive. The reader is built only when that
409
+ // directory holds a month file, so a ledger that never archives pays one failed
410
+ // directory listing per start and reads nothing else.
411
+ const archive = root === '' ? null : await openLedgerArchive(root)
412
+ const shardStore = createLedgerUnitStore(undefined, shardUnits, root, undefined, archive)
190
413
  // A per-record unit exposes tableDir(), so its documents can be listed and read
191
414
  // one at a time. Older or fake units cannot, and there the whole table is read
192
415
  // once (and stays resident) exactly as it always was.
@@ -200,23 +423,52 @@ async function openLedgerUnitStore(backend) {
200
423
  return { store: shardStore, rows: shardRows, retired, streaming }
201
424
  }
202
425
 
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 }
426
+ // The first migration writes the marker and then retires the legacy files, so
427
+ // the whole section is one critical section under the maintenance lock: a
428
+ // second process must never delete a file this one is still reading. An empty
429
+ // root has nothing to retire, so it keeps the unlocked behaviour.
430
+ const readLegacyRows = async () => {
431
+ // Read the legacy monolith only during the one-time migration. Once shard 00
432
+ // carries the marker, startup never parses the old multi-megabyte file again.
433
+ legacyUnit = await backend.kv.open({ name: LEDGER_UNIT_NAME, version: 0, tables: ['sessions'], hasGlobal: false })
434
+ return sessionRows(await legacyUnit.loadAll())
435
+ }
436
+ const mergedRows = (legacyRows) => {
437
+ const rows = Object.assign(Object.create(null), shardRows)
438
+ for (const [key, value] of Object.entries(legacyRows)) {
439
+ if (key === LEDGER_META_KEY || Object.hasOwn(rows, key)) continue
440
+ rows[key] = value
441
+ }
442
+ return rows
443
+ }
444
+ const migrate = async (lock) => {
445
+ const legacyRows = await readLegacyRows()
446
+ const rows = mergedRows(legacyRows)
447
+ const store = createLedgerUnitStore(legacyUnit, shardUnits, '', undefined, archive)
448
+ // A row the tree already holds is the newer one (a previous migration may have
449
+ // been interrupted after copying it), so only the missing rows are copied. A
450
+ // streaming backend keeps its trees on disk, so there it copies every row
451
+ // exactly as before.
452
+ for (const [key, value] of Object.entries(legacyRows)) {
453
+ if (key === LEDGER_META_KEY || Object.hasOwn(shardRows, key)) continue
454
+ await store.putRecord('sessions', key, value)
455
+ }
456
+ await store.putRecord('sessions', LEDGER_META_KEY, { version: 1, migratedAt: Date.now() })
457
+ // Those rows now live in the per-record trees (written above), so the legacy
458
+ // document is retired. A failure here leaves the file in place and simply
459
+ // tries again on the next start.
460
+ const retired = await retireLegacyLedgerFiles(backend, shardUnits, lock).catch((error) => { console.error('[all-usage] usage ledger legacy retirement failed:', error && error.message); return [] })
461
+ return { store, rows, retired, streaming: false }
462
+ }
463
+ if (root === '') return await migrate(undefined)
464
+ const outcome = await withLedgerMaintenanceLock(root, migrate)
465
+ if (outcome.acquired) return outcome.value
466
+ // Another process owns the maintenance section: merge what it has not copied
467
+ // into the trees yet (the trees win) and write nothing at all. The legacy
468
+ // document stays where it is, and its rows stay readable through the store
469
+ // until a start that holds the lock finishes the migration.
470
+ const legacyRows = await readLegacyRows()
471
+ return { store: createLedgerUnitStore(legacyUnit, shardUnits, root, legacyRows, archive), rows: mergedRows(legacyRows), retired: [], streaming }
220
472
  } catch (error) {
221
473
  await Promise.all(uniqueUnits([legacyUnit, ...shardUnits]).map((unit) => unit.close().catch(() => {})))
222
474
  throw error
@@ -227,14 +479,29 @@ async function openLedgerUnitStore(backend) {
227
479
  // is dead weight: every record lives in the tree now, while the old document
228
480
  // keeps a full copy (155.9 MB across the 32 shards plus a 22.4 MB monolith on
229
481
  // 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) {
482
+ // itself — only after the tree is non-empty, only for files it owns, never more
483
+ // than once per unit (a marker keeps a failed run from looping), and never
484
+ // without the maintenance lock: deleting a whole-unit file another process is
485
+ // still loading would lose the rows it has not copied yet.
486
+ async function retireLegacyLedgerFiles(backend, shardUnits, heldLock = undefined) {
233
487
  if (process.env.DSH_ALL_USAGE_KEEP_LEGACY === '1') return []
234
488
  const root = typeof backend.root === 'string' ? backend.root : ''
235
489
  if (root === '') return []
236
490
  const marker = join(root, LEDGER_UNIT_NAME + '.retired')
237
491
  if (await stat(marker).then(() => true, () => false)) return []
492
+ // The migration already holds the lock and hands its handle over, so one
493
+ // critical section covers the copy and the retirement.
494
+ const lock = heldLock === undefined ? await acquireMaintenanceLock(root) : heldLock
495
+ if (lock === null) return []
496
+ try {
497
+ return await collectLegacyRetirement(root, shardUnits, marker)
498
+ } finally {
499
+ if (heldLock === undefined) await releaseMaintenanceLock(lock)
500
+ }
501
+ }
502
+
503
+ // The retirement proper, only ever entered while holding the maintenance lock.
504
+ async function collectLegacyRetirement(root, shardUnits, marker) {
238
505
  const retired = []
239
506
  const candidates = []
240
507
  for (let index = 0; index < LEDGER_SHARD_COUNT; index += 1) {
@@ -664,6 +931,46 @@ function buildLedgerRecord(session, workspaceId, source = 'scan', revision, prev
664
931
  if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
665
932
  }
666
933
  }
934
+ /**
935
+ * Cold-archive the ledger from the running host: fold every record older than
936
+ * days days into <root>/archive/<YYYY-MM>.jsonl.gz, and only then remove the tree
937
+ * documents that are durable there. The panel asks for the plan first (dryRun)
938
+ * and only a second, explicit request writes. Either way the run is one
939
+ * maintenance step behind the same lock the CLI and the migration take, so two
940
+ * processes never archive the same root at once.
941
+ *
942
+ * Returns { ok: true, months, archived, bytes, compressed, dryRun, errors }, or
943
+ * { ok: false, message } with message no-root | ledger-unavailable | busy |
944
+ * archive-failed.
945
+ */
946
+ async function archiveLedger({ days = 90, dryRun = true } = {}) {
947
+ try {
948
+ const backend = storage === undefined || storage === null || storage.backend === undefined || storage.backend === null ? undefined : storage.backend.get('json')
949
+ const root = backend !== undefined && backend !== null && typeof backend.root === 'string' ? backend.root : ''
950
+ if (root === '') return { ok: false, message: 'no-root' }
951
+ // A record still queued for its tree would be planned from the revision before
952
+ // the last write; the plan reads the documents the delete will remove.
953
+ await drainLedgerWrites()
954
+ const unit = state.ledgerUnit
955
+ if (unit === null || unit === undefined || typeof unit.treeStore !== 'function') return { ok: false, message: 'ledger-unavailable' }
956
+ const cutoffMs = Date.now() - days * 86400000
957
+ // treeStore(), never the unit itself: the unit answers from the archive, so a
958
+ // second run would plan the records the first one filed all over again.
959
+ const outcome = await withLedgerMaintenanceLock(root, () => archiveRecords({ root, store: unit.treeStore(), cutoffMs, dryRun: dryRun === true }))
960
+ if (!outcome.acquired) return { ok: false, message: 'busy' }
961
+ if (dryRun !== true) {
962
+ // The month files are on disk and their tree documents are gone: the reader
963
+ // this process opened at start knows neither. attachArchive() answers for
964
+ // those records now instead of after the next DSH start.
965
+ const reader = await openLedgerArchive(root)
966
+ unit.attachArchive(reader)
967
+ if (reader === null) console.warn('[all-usage] usage ledger archive holds no readable month; the records it removed read back after the next start')
968
+ }
969
+ return { ok: true, ...outcome.value }
970
+ } catch (error) {
971
+ return { ok: false, message: 'archive-failed', error: String(error && error.message ? error.message : error) }
972
+ }
973
+ }
667
974
  function recordCacheLimit() {
668
975
  const megabytes = Number(process.env.DSH_ALL_USAGE_RECORD_CACHE_MB)
669
976
  return Number.isFinite(megabytes) && megabytes >= 0 ? megabytes * 1024 * 1024 : 16 * 1024 * 1024
@@ -982,6 +1289,7 @@ function buildLedgerRecord(session, workspaceId, source = 'scan', revision, prev
982
1289
  storeLedgerRecord,
983
1290
  persistLedgerRecord,
984
1291
  drainLedgerWrites,
1292
+ archiveLedger,
985
1293
  // Exposed for the round-trip test: the on-disk shape is private otherwise.
986
1294
  serializeLedgerRecordForTest: serializeLedgerRecord,
987
1295
  expandLedgerRecordForTest: expandLedgerRecord,
@@ -997,4 +1305,4 @@ function buildLedgerRecord(session, workspaceId, source = 'scan', revision, prev
997
1305
  }
998
1306
  }
999
1307
 
1000
- export { LEDGER_VERSION, PREVIOUS_LEDGER_VERSION, LEGACY_LEDGER_VERSION, LEDGER_SHARD_COUNT, RETIRED_WORKSPACE_ID, ledgerShardIndex, retargetLedgerRecord, serializeLedgerRecord }
1308
+ export { LEDGER_VERSION, PREVIOUS_LEDGER_VERSION, LEGACY_LEDGER_VERSION, LEDGER_SHARD_COUNT, RETIRED_WORKSPACE_ID, ledgerShardIndex, retargetLedgerRecord, serializeLedgerRecord, withLedgerMaintenanceLock }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-all-usage",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "DeepSeek Harness usage dashboard with model, provider, workspace, cache, balance, and CSV insights",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,6 +17,7 @@
17
17
  "fixture:check": "node scripts/replay-fixture.mjs fixtures/usage-events.json",
18
18
  "test:runtime": "node --test test/dsh-runtime.test.js",
19
19
  "check:pack": "node scripts/check-package.mjs",
20
+ "ledger:archive": "node scripts/archive-ledger.mjs",
20
21
  "prepack": "npm run build:client"
21
22
  },
22
23
  "main": "lib/index.js",
@@ -50,6 +51,7 @@
50
51
  "lib/index.js",
51
52
  "lib/plugin.js",
52
53
  "lib/aggregation.js",
54
+ "lib/archive.js",
53
55
  "lib/records-cache.js",
54
56
  "lib/ledger.js",
55
57
  "lib/session-sync.js",
@@ -64,10 +66,17 @@
64
66
  "CHANGELOG.md",
65
67
  "README.md",
66
68
  "README.en.md",
69
+ "README.ja.md",
70
+ "README.es.md",
71
+ "README.fr.md",
72
+ "README.de.md",
73
+ "README.ru.md",
74
+ "README.pt.md",
67
75
  "cordis.patch.yml",
68
76
  "screenshots.json",
69
77
  "fixtures/usage-events.json",
70
78
  "scripts/replay-fixture.mjs",
79
+ "scripts/archive-ledger.mjs",
71
80
  "assets"
72
81
  ],
73
82
  "keywords": [
package/screenshots.json CHANGED
@@ -1,12 +1,10 @@
1
1
  [
2
+ "assets/cover-zh.png",
2
3
  "assets/screenshots/zh/01-overview.png",
3
- "assets/screenshots/zh/02-heatmap.png",
4
- "assets/screenshots/zh/03-model-usage.png",
5
- "assets/screenshots/zh/04-request-logs.png",
6
- "assets/screenshots/zh/05-cost-settings.png",
7
- "assets/screenshots/en/01-overview.png",
8
- "assets/screenshots/en/02-heatmap.png",
9
- "assets/screenshots/en/03-model-usage.png",
10
- "assets/screenshots/en/04-request-logs.png",
11
- "assets/screenshots/en/05-cost-settings.png"
4
+ "assets/screenshots/zh/02-trend.png",
5
+ "assets/screenshots/zh/03-heatmap.png",
6
+ "assets/screenshots/zh/04-model-stats.png",
7
+ "assets/screenshots/zh/05-request-logs.png",
8
+ "assets/screenshots/zh/06-cost-settings.png",
9
+ "assets/screenshots/zh/07-maintenance.png"
12
10
  ]