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.
- package/CHANGELOG.md +40 -0
- package/README.de.md +316 -0
- package/README.en.md +52 -14
- package/README.es.md +316 -0
- package/README.fr.md +316 -0
- package/README.ja.md +316 -0
- package/README.md +55 -17
- package/README.pt.md +316 -0
- package/README.ru.md +316 -0
- package/assets/cover-en.png +0 -0
- package/assets/cover-zh.png +0 -0
- package/assets/readme-architecture-en.svg +95 -95
- package/assets/readme-architecture-zh.svg +95 -95
- package/assets/readme-sync-en.svg +81 -81
- package/assets/readme-sync-zh.svg +81 -81
- package/assets/screenshots/en/01-overview.png +0 -0
- package/assets/screenshots/en/02-trend-bars.png +0 -0
- package/assets/screenshots/en/02-trend.png +0 -0
- package/assets/screenshots/en/03-heatmap.png +0 -0
- package/assets/screenshots/en/04-model-stats.png +0 -0
- package/assets/screenshots/en/05-request-logs.png +0 -0
- package/assets/screenshots/en/06-cost-settings.png +0 -0
- package/assets/screenshots/en/07-maintenance.png +0 -0
- package/assets/screenshots/zh/01-overview.png +0 -0
- package/assets/screenshots/zh/02-trend-bars.png +0 -0
- package/assets/screenshots/zh/02-trend.png +0 -0
- package/assets/screenshots/zh/03-heatmap.png +0 -0
- package/assets/screenshots/zh/04-model-stats.png +0 -0
- package/assets/screenshots/zh/05-request-logs.png +0 -0
- package/assets/screenshots/zh/06-cost-settings.png +0 -0
- package/assets/screenshots/zh/07-maintenance.png +0 -0
- package/lib/archive.js +399 -0
- package/lib/client.js +1 -1
- package/lib/http.js +27 -1
- package/lib/ledger.js +362 -54
- package/package.json +10 -1
- package/screenshots.json +7 -9
- package/scripts/archive-ledger.mjs +156 -0
- package/assets/screenshots/en/04-model-usage.png +0 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
await
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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,
|
|
231
|
-
//
|
|
232
|
-
|
|
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.
|
|
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-
|
|
4
|
-
"assets/screenshots/zh/03-
|
|
5
|
-
"assets/screenshots/zh/04-
|
|
6
|
-
"assets/screenshots/zh/05-
|
|
7
|
-
"assets/screenshots/
|
|
8
|
-
"assets/screenshots/
|
|
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
|
]
|