dsh-all-usage 1.2.3 → 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 +22 -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 -16
  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 +5 -3
  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
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ // Cold-archives the usage ledger.
3
+ //
4
+ // node scripts/archive-ledger.mjs --root <dir> [--older-than-days 90] [--apply]
5
+ //
6
+ // Every session record whose updatedAt is older than the cutoff is folded into
7
+ // <root>/archive/<YYYY-MM>.jsonl.gz; only after that month's record file, its key
8
+ // index and the manifest are on disk is the per-record document removed. The
9
+ // default is a dry run that prints the plan and writes nothing at all.
10
+ //
11
+ // The whole run happens behind the ledger's maintenance lock. Losing that race is
12
+ // not an error to retry blindly: another process is migrating or archiving the same
13
+ // root, so this command exits 2 without touching a single file.
14
+ import { readFile, readdir, unlink } from 'node:fs/promises'
15
+ import { join, resolve } from 'node:path'
16
+ import { archiveRecords } from '../lib/archive.js'
17
+ import { withLedgerMaintenanceLock } from '../lib/ledger.js'
18
+
19
+ const USAGE = 'usage: node scripts/archive-ledger.mjs --root <dir> [--older-than-days 90] [--apply]'
20
+ const DEFAULT_DAYS = 90
21
+ const DAY_MS = 86400000
22
+ // The per-record trees the ledger keeps next to the archive. The legacy whole-unit
23
+ // files (all_usage_ledger_NN.json) are not trees and are left alone: the ledger
24
+ // retires them itself once the trees hold their rows.
25
+ const SHARD_DIR_PATTERN = /^all_usage_ledger_\d{2}$/
26
+
27
+ function parseArgs(argv) {
28
+ const options = { root: '', days: DEFAULT_DAYS, apply: false }
29
+ for (let index = 0; index < argv.length; index += 1) {
30
+ const arg = argv[index]
31
+ if (arg === '--apply') { options.apply = true; continue }
32
+ if (arg === '--help' || arg === '-h') return null
33
+ const equals = arg.indexOf('=')
34
+ const name = equals === -1 ? arg : arg.slice(0, equals)
35
+ if (name !== '--root' && name !== '--older-than-days') throw new Error('unknown argument: ' + arg)
36
+ let value
37
+ if (equals !== -1) value = arg.slice(equals + 1)
38
+ else {
39
+ value = argv[index + 1]
40
+ if (value === undefined) throw new Error('missing value for ' + name)
41
+ index += 1
42
+ }
43
+ if (name === '--root') { options.root = value; continue }
44
+ const days = Number(value)
45
+ if (!Number.isFinite(days) || days < 0) throw new Error('--older-than-days must be a non-negative number of days')
46
+ options.days = days
47
+ }
48
+ if (options.root === '') throw new Error('--root is required')
49
+ return options
50
+ }
51
+
52
+ // The ledger's hot copy without a DSH storage backend: the per-record trees under
53
+ // <root>/all_usage_ledger_NN/sessions. The shard a session hashes to is not
54
+ // recomputed here - one listing of every tree answers listKeys() and readRecord()
55
+ // together, and a document can only sit in the tree it was found in.
56
+ async function openTreeStore(root) {
57
+ const trees = []
58
+ for (const entry of await readdir(root, { withFileTypes: true }).catch(() => [])) {
59
+ if (entry.isDirectory() && SHARD_DIR_PATTERN.test(entry.name)) trees.push(join(root, entry.name, 'sessions'))
60
+ }
61
+ const paths = new Map()
62
+ async function index() {
63
+ paths.clear()
64
+ for (const dir of trees) {
65
+ for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
66
+ if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name.startsWith('__')) continue
67
+ paths.set(entry.name.slice(0, -'.json'.length), join(dir, entry.name))
68
+ }
69
+ }
70
+ }
71
+ return {
72
+ async listKeys() {
73
+ await index()
74
+ return Array.from(paths.keys())
75
+ },
76
+ async readRecord(key) {
77
+ const path = paths.get(key)
78
+ if (path === undefined) return undefined
79
+ const doc = JSON.parse(await readFile(path, 'utf8'))
80
+ if (doc === null || typeof doc !== 'object' || doc.version !== 0 || doc.record === undefined) return undefined
81
+ return doc.record
82
+ },
83
+ async deleteRecord(key, expected) {
84
+ const path = paths.get(key)
85
+ if (path === undefined) return false
86
+ // Same optimistic check as the ledger's own store: a session that published a
87
+ // newer revision while this run was planning keeps its document, and the next
88
+ // run archives that revision instead of losing it.
89
+ if (expected !== undefined) {
90
+ if (expected === null || typeof expected !== 'object') return false
91
+ let doc = null
92
+ try { doc = JSON.parse(await readFile(path, 'utf8')) } catch (error) { return false }
93
+ if (doc === null || typeof doc !== 'object' || doc.record === null || typeof doc.record !== 'object') return false
94
+ if (doc.record.updatedAt !== expected.updatedAt) return false
95
+ if (JSON.stringify(doc.record) !== JSON.stringify(expected)) return false
96
+ }
97
+ paths.delete(key)
98
+ return unlink(path).then(() => true, () => false)
99
+ },
100
+ }
101
+ }
102
+
103
+ function formatBytes(bytes) {
104
+ if (bytes < 1024) return bytes + ' B'
105
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
106
+ return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
107
+ }
108
+
109
+ async function main() {
110
+ let options
111
+ try {
112
+ options = parseArgs(process.argv.slice(2))
113
+ } catch (error) {
114
+ console.error('archive-ledger: ' + error.message)
115
+ console.error(USAGE)
116
+ return 2
117
+ }
118
+ if (options === null) {
119
+ console.log(USAGE)
120
+ return 0
121
+ }
122
+ const root = resolve(options.root)
123
+ const cutoffMs = Date.now() - options.days * DAY_MS
124
+ const mode = options.apply ? 'apply' : 'dry run (pass --apply to write)'
125
+ console.log('all-usage ledger archive')
126
+ console.log(' root: ' + root)
127
+ console.log(' cutoff: ' + new Date(cutoffMs).toISOString() + ' (' + options.days + ' days old or older)')
128
+ console.log(' mode: ' + mode)
129
+ const store = await openTreeStore(root)
130
+ try {
131
+ const outcome = await withLedgerMaintenanceLock(root, () => archiveRecords({ root, store, cutoffMs, dryRun: !options.apply }))
132
+ if (!outcome.acquired) {
133
+ console.error('[all-usage] another process holds the maintenance lock in ' + root + ': nothing was written')
134
+ return 2
135
+ }
136
+ const result = outcome.value
137
+ if (result.months.length === 0 && result.archived === 0) {
138
+ console.log(' nothing to archive: no record is older than the cutoff')
139
+ return 0
140
+ }
141
+ console.log(' months: ' + result.months.join(', '))
142
+ console.log(' records: ' + result.archived)
143
+ console.log(' bytes: ' + formatBytes(result.bytes) + ' of records' + (options.apply ? ' -> ' + formatBytes(result.compressed) + ' of month files' : ''))
144
+ if (result.errors.length > 0) {
145
+ for (const error of result.errors) console.error('[all-usage] archive failed for ' + (error.month === undefined ? error.key : error.month) + ': ' + error.message)
146
+ console.error('[all-usage] ' + result.errors.length + ' record group(s) stayed in the ledger trees')
147
+ return 1
148
+ }
149
+ return 0
150
+ } catch (error) {
151
+ console.error('[all-usage] archive-ledger failed: ' + (error && error.stack ? error.stack : error))
152
+ return 1
153
+ }
154
+ }
155
+
156
+ process.exitCode = await main()