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.
- package/CHANGELOG.md +22 -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 -16
- 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 +5 -3
- 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/archive.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// Cold storage for usage ledger records.
|
|
2
|
+
//
|
|
3
|
+
// The per-record trees (<root>/all_usage_ledger_NN/sessions/<id>.json) are the hot
|
|
4
|
+
// copy of the ledger: a write publishes one document, a query reads back only the
|
|
5
|
+
// documents it asks for. A session nobody has touched for months still costs a
|
|
6
|
+
// document and a directory entry, and that is the cost this module buys back.
|
|
7
|
+
//
|
|
8
|
+
// A cold record is folded into <root>/archive/<YYYY-MM>.jsonl.gz, one compact row
|
|
9
|
+
// per line (the exact shape serializeLedgerRecord() writes to the trees), and only
|
|
10
|
+
// then is its document removed. Whole records are stored, never aggregates: the
|
|
11
|
+
// read path can put a row back through the same fold that produced it, so the
|
|
12
|
+
// archive cannot disagree with the tree about a single token.
|
|
13
|
+
//
|
|
14
|
+
// archive/<YYYY-MM>.jsonl.gz the month's records, one JSON document per line
|
|
15
|
+
// archive/<YYYY-MM>.keys.json.gz that month's session ids (the listKeys index)
|
|
16
|
+
// archive/manifest.json {version: 1, months: {...}} - a description only
|
|
17
|
+
//
|
|
18
|
+
// A month is decided by record.updatedAt in UTC, so a record always lands in the
|
|
19
|
+
// same file however the machine's time zone moves.
|
|
20
|
+
//
|
|
21
|
+
// Durability order, per month: record file, key index, manifest, source documents.
|
|
22
|
+
// Every one of the first three is written as <name>.tmp, fsynced, closed and then
|
|
23
|
+
// renamed over its target, so a reader never sees a half-written file. The tree
|
|
24
|
+
// documents are unlinked only once all three are in place; a failure in any layer
|
|
25
|
+
// leaves the record in the tree as well. That is the one state the read path has to
|
|
26
|
+
// tolerate - it prefers the tree, so the record is still folded once, and the next
|
|
27
|
+
// archive run finds a duplicate it does not have to write again.
|
|
28
|
+
import { mkdir as makeDir, open as openFile, readFile as readFileBytes, readdir as readDir, rename as renameFile, stat as statFile, unlink as unlinkFile } from 'node:fs/promises'
|
|
29
|
+
import { join } from 'node:path'
|
|
30
|
+
import { promisify } from 'node:util'
|
|
31
|
+
import { gunzip as gunzipCallback, gzip as gzipCallback } from 'node:zlib'
|
|
32
|
+
|
|
33
|
+
const ARCHIVE_DIR = 'archive'
|
|
34
|
+
const MANIFEST_FILE = 'manifest.json'
|
|
35
|
+
const MANIFEST_VERSION = 1
|
|
36
|
+
const RECORD_SUFFIX = '.jsonl.gz'
|
|
37
|
+
const KEYS_SUFFIX = '.keys.json.gz'
|
|
38
|
+
const MONTH_FILE_PATTERN = /^(\d{4}-\d{2})\.jsonl\.gz$/
|
|
39
|
+
const gzip = promisify(gzipCallback)
|
|
40
|
+
const gunzip = promisify(gunzipCallback)
|
|
41
|
+
|
|
42
|
+
// node:fs/promises cannot be substituted by a test, so both public entry points
|
|
43
|
+
// accept an fs object. Production never passes one; the archive tests hand in a
|
|
44
|
+
// wrapper that counts calls, which is the only way to prove that a ledger without
|
|
45
|
+
// an archive directory does not pay for one.
|
|
46
|
+
const DEFAULT_FS = {
|
|
47
|
+
open: openFile,
|
|
48
|
+
mkdir: makeDir,
|
|
49
|
+
readFile: readFileBytes,
|
|
50
|
+
readdir: readDir,
|
|
51
|
+
rename: renameFile,
|
|
52
|
+
stat: statFile,
|
|
53
|
+
unlink: unlinkFile,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fsOf(options) {
|
|
57
|
+
if (options === null || typeof options !== 'object') return DEFAULT_FS
|
|
58
|
+
if (options.fs === null || typeof options.fs !== 'object') return DEFAULT_FS
|
|
59
|
+
return options.fs
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The UTC month a record belongs to. Rows without a usable instant cannot be
|
|
63
|
+
// bucketed (and are already invalid to the ledger) and stay where they are.
|
|
64
|
+
function monthOfRecord(record) {
|
|
65
|
+
if (record === null || typeof record !== 'object') return undefined
|
|
66
|
+
if (!Number.isFinite(record.updatedAt)) return undefined
|
|
67
|
+
const date = new Date(record.updatedAt)
|
|
68
|
+
if (!Number.isFinite(date.getTime())) return undefined
|
|
69
|
+
return date.toISOString().slice(0, 7)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function updatedAtOf(record) {
|
|
73
|
+
return record !== null && typeof record === 'object' && Number.isFinite(record.updatedAt) ? record.updatedAt : -Infinity
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// One fsynced, atomic publish. The staging name carries the pid so two processes
|
|
77
|
+
// that somehow slipped past the maintenance lock cannot truncate each other's file.
|
|
78
|
+
async function writeDurable(fs, path, data) {
|
|
79
|
+
const tmp = path + '.tmp-' + String(process.pid)
|
|
80
|
+
let handle = null
|
|
81
|
+
try {
|
|
82
|
+
handle = await fs.open(tmp, 'w')
|
|
83
|
+
await handle.writeFile(data)
|
|
84
|
+
await handle.sync()
|
|
85
|
+
await handle.close()
|
|
86
|
+
handle = null
|
|
87
|
+
await fs.rename(tmp, path)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (handle !== null) await handle.close().catch(() => {})
|
|
90
|
+
await fs.unlink(tmp).catch(() => {})
|
|
91
|
+
throw error
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// One month file, decompressed and parsed into one row per session id. An
|
|
96
|
+
// unreadable file throws: the writer must never replace a month it cannot read,
|
|
97
|
+
// because that would trade a damaged row for every row in it.
|
|
98
|
+
async function readArchiveMonth(fs, dir, month) {
|
|
99
|
+
const packed = await fs.readFile(join(dir, month + RECORD_SUFFIX)).catch((error) => {
|
|
100
|
+
if (error !== null && typeof error === 'object' && error.code === 'ENOENT') return null
|
|
101
|
+
throw error
|
|
102
|
+
})
|
|
103
|
+
if (packed === null) return new Map()
|
|
104
|
+
const text = (await gunzip(packed)).toString('utf8')
|
|
105
|
+
const rows = new Map()
|
|
106
|
+
for (const line of text.split('\n')) {
|
|
107
|
+
if (line === '') continue
|
|
108
|
+
const row = JSON.parse(line)
|
|
109
|
+
if (row === null || typeof row !== 'object' || typeof row.sessionId !== 'string') continue
|
|
110
|
+
rows.set(row.sessionId, row)
|
|
111
|
+
}
|
|
112
|
+
return rows
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readManifest(fs, root) {
|
|
116
|
+
const packed = await fs.readFile(join(root, ARCHIVE_DIR, MANIFEST_FILE)).catch(() => null)
|
|
117
|
+
if (packed === null) return { version: MANIFEST_VERSION, months: {} }
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(packed.toString('utf8'))
|
|
120
|
+
if (parsed !== null && typeof parsed === 'object' && parsed.version === MANIFEST_VERSION && parsed.months !== null && typeof parsed.months === 'object') {
|
|
121
|
+
return { version: MANIFEST_VERSION, months: parsed.months }
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
// A damaged manifest is a description, not the archive: the months on disk
|
|
125
|
+
// decide what is readable, and this one is simply rewritten from them.
|
|
126
|
+
}
|
|
127
|
+
return { version: MANIFEST_VERSION, months: {} }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function writeManifest(fs, root, manifest) {
|
|
131
|
+
const months = {}
|
|
132
|
+
for (const month of Object.keys(manifest.months).sort()) months[month] = manifest.months[month]
|
|
133
|
+
const body = JSON.stringify({ version: MANIFEST_VERSION, months }, null, 1) + '\n'
|
|
134
|
+
await writeDurable(fs, join(root, ARCHIVE_DIR, MANIFEST_FILE), Buffer.from(body, 'utf8'))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The read side of the archive, or null when <root>/archive does not exist.
|
|
139
|
+
*
|
|
140
|
+
* The directory listing is the whole feature switch, so a ledger that never
|
|
141
|
+
* archived pays one failed listing per start and reads nothing else. The key index
|
|
142
|
+
* is loaded eagerly because listKeys() is a synchronous question and the
|
|
143
|
+
* .keys.json.gz files are small; the record files are decompressed one month at a
|
|
144
|
+
* time, on the first read that needs them, and stay in memory afterwards.
|
|
145
|
+
*
|
|
146
|
+
* A month that cannot be decompressed or parsed is treated as missing: a warning
|
|
147
|
+
* is logged once, its session ids leave the index, and startup carries on with the
|
|
148
|
+
* months that are readable.
|
|
149
|
+
*/
|
|
150
|
+
export async function createArchiveReader(root, options = {}) {
|
|
151
|
+
if (typeof root !== 'string' || root === '') return null
|
|
152
|
+
const fs = fsOf(options)
|
|
153
|
+
const dir = join(root, ARCHIVE_DIR)
|
|
154
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => null)
|
|
155
|
+
if (entries === null) return null
|
|
156
|
+
const months = []
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
const match = entry.isFile() ? MONTH_FILE_PATTERN.exec(entry.name) : null
|
|
159
|
+
if (match === null || months.includes(match[1])) continue
|
|
160
|
+
months.push(match[1])
|
|
161
|
+
}
|
|
162
|
+
if (months.length === 0) return null
|
|
163
|
+
months.sort()
|
|
164
|
+
|
|
165
|
+
const records = new Map()
|
|
166
|
+
const monthKeys = new Map()
|
|
167
|
+
const keyMonths = new Map()
|
|
168
|
+
const broken = new Set()
|
|
169
|
+
const derived = new Set()
|
|
170
|
+
const warned = new Set()
|
|
171
|
+
const sessionIds = new Set()
|
|
172
|
+
|
|
173
|
+
function rebuildSessionIds() {
|
|
174
|
+
sessionIds.clear()
|
|
175
|
+
for (const [month, keys] of monthKeys) {
|
|
176
|
+
if (broken.has(month)) continue
|
|
177
|
+
for (const key of keys) sessionIds.add(key)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function loadRecords(month) {
|
|
182
|
+
if (records.has(month)) return records.get(month)
|
|
183
|
+
let rows = null
|
|
184
|
+
try {
|
|
185
|
+
const packed = await fs.readFile(join(dir, month + RECORD_SUFFIX))
|
|
186
|
+
const text = (await gunzip(packed)).toString('utf8')
|
|
187
|
+
rows = new Map()
|
|
188
|
+
for (const line of text.split('\n')) {
|
|
189
|
+
if (line === '') continue
|
|
190
|
+
const row = JSON.parse(line)
|
|
191
|
+
if (row === null || typeof row !== 'object' || typeof row.sessionId !== 'string') continue
|
|
192
|
+
rows.set(row.sessionId, row)
|
|
193
|
+
}
|
|
194
|
+
} catch (error) {
|
|
195
|
+
rows = null
|
|
196
|
+
if (!warned.has(month)) {
|
|
197
|
+
warned.add(month)
|
|
198
|
+
console.warn('[all-usage] usage ledger archive month is unreadable and is treated as missing:', month, error && error.message)
|
|
199
|
+
}
|
|
200
|
+
if (!broken.has(month)) {
|
|
201
|
+
broken.add(month)
|
|
202
|
+
rebuildSessionIds()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
records.set(month, rows)
|
|
206
|
+
return rows
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function indexMonth(month, keys) {
|
|
210
|
+
monthKeys.set(month, keys)
|
|
211
|
+
for (const key of keys) {
|
|
212
|
+
let owners = keyMonths.get(key)
|
|
213
|
+
if (owners === undefined) { owners = new Set(); keyMonths.set(key, owners) }
|
|
214
|
+
owners.add(month)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const month of months) {
|
|
219
|
+
let keys = null
|
|
220
|
+
try {
|
|
221
|
+
const packed = await fs.readFile(join(dir, month + KEYS_SUFFIX))
|
|
222
|
+
const parsed = JSON.parse((await gunzip(packed)).toString('utf8'))
|
|
223
|
+
if (Array.isArray(parsed)) keys = parsed.filter((key) => typeof key === 'string' && key !== '')
|
|
224
|
+
} catch (error) {
|
|
225
|
+
keys = null
|
|
226
|
+
}
|
|
227
|
+
if (keys !== null) { indexMonth(month, keys); continue }
|
|
228
|
+
// Without a usable index the month file itself is the only record of what the
|
|
229
|
+
// month holds, and dropping those sessions from listKeys() would hide usage
|
|
230
|
+
// that is still perfectly readable. One decompression buys the whole list.
|
|
231
|
+
const rows = await loadRecords(month)
|
|
232
|
+
if (rows === null) continue
|
|
233
|
+
derived.add(month)
|
|
234
|
+
indexMonth(month, Array.from(rows.keys()))
|
|
235
|
+
}
|
|
236
|
+
rebuildSessionIds()
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
months: Object.freeze(months.slice()),
|
|
240
|
+
/** Every session id the archive holds, in no particular order. */
|
|
241
|
+
keys() { return new Set(sessionIds) },
|
|
242
|
+
/**
|
|
243
|
+
* One archived row, in the shape the trees store (compact, identity pool and
|
|
244
|
+
* all): the ledger expands it with expandLedgerRecord() like any other row.
|
|
245
|
+
* A session archived in two months is one record; the newer revision wins.
|
|
246
|
+
*/
|
|
247
|
+
async readRecord(key) {
|
|
248
|
+
if (typeof key !== 'string' || key === '') return undefined
|
|
249
|
+
const owners = keyMonths.get(key)
|
|
250
|
+
// A key the index never saw can still sit in a month whose index was derived
|
|
251
|
+
// or missing, so those months are still searched. A complete index answers
|
|
252
|
+
// 'missing' without decompressing anything.
|
|
253
|
+
const candidates = owners !== undefined ? months.filter((month) => owners.has(month)) : Array.from(derived)
|
|
254
|
+
let best
|
|
255
|
+
for (const month of candidates) {
|
|
256
|
+
if (broken.has(month)) continue
|
|
257
|
+
const rows = await loadRecords(month)
|
|
258
|
+
if (rows === null) continue
|
|
259
|
+
const row = rows.get(key)
|
|
260
|
+
if (row === undefined) continue
|
|
261
|
+
if (best === undefined || updatedAtOf(row) > updatedAtOf(best)) best = row
|
|
262
|
+
}
|
|
263
|
+
return best
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Fold every tree record older than cutoffMs into its month file and, once that is
|
|
270
|
+
* durable, remove the tree document.
|
|
271
|
+
*
|
|
272
|
+
* The store is the ledger's unit store (or the filesystem stand-in the CLI builds):
|
|
273
|
+
* listKeys() names the records, readRecord() returns the compact row, and
|
|
274
|
+
* deleteRecord(key, expected) removes one tree document only while it still holds
|
|
275
|
+
* the row named by expected. Only updatedAt decides membership, so a record that is
|
|
276
|
+
* still being written before the cutoff and again after it can never be archived
|
|
277
|
+
* twice with different content, and a write that lands during the run keeps its
|
|
278
|
+
* document instead of losing the revision.
|
|
279
|
+
*
|
|
280
|
+
* Returns { months, archived, bytes, compressed, dryRun, errors }. months lists the
|
|
281
|
+
* month files written, archived counts the records this run moved, bytes is their
|
|
282
|
+
* serialized (uncompressed) size, compressed the bytes those month files grew by.
|
|
283
|
+
* A dry run reports the same plan and writes nothing at all - not even the archive
|
|
284
|
+
* directory.
|
|
285
|
+
*/
|
|
286
|
+
export async function archiveRecords({ root, store, cutoffMs, dryRun = false, fs: fsOption } = {}) {
|
|
287
|
+
if (typeof root !== 'string' || root === '') throw new TypeError('archiveRecords needs a ledger root')
|
|
288
|
+
if (store === null || typeof store !== 'object') throw new TypeError('archiveRecords needs a ledger store')
|
|
289
|
+
if (typeof store.listKeys !== 'function' || typeof store.readRecord !== 'function' || typeof store.deleteRecord !== 'function') throw new TypeError('archiveRecords needs store.listKeys(), store.readRecord() and store.deleteRecord()')
|
|
290
|
+
if (!Number.isFinite(cutoffMs)) throw new TypeError('archiveRecords needs a finite cutoffMs')
|
|
291
|
+
const fs = fsOf({ fs: fsOption })
|
|
292
|
+
const result = { months: [], archived: 0, bytes: 0, compressed: 0, dryRun: dryRun === true, errors: [] }
|
|
293
|
+
const byMonth = new Map()
|
|
294
|
+
let planned = 0
|
|
295
|
+
let plannedBytes = 0
|
|
296
|
+
for (const key of await store.listKeys()) {
|
|
297
|
+
if (typeof key !== 'string' || key === '' || key.startsWith('__')) continue
|
|
298
|
+
let row
|
|
299
|
+
try {
|
|
300
|
+
row = await store.readRecord(key)
|
|
301
|
+
} catch (error) {
|
|
302
|
+
result.errors.push({ key, message: error && error.message ? error.message : String(error) })
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
if (row === null || typeof row !== 'object' || typeof row.sessionId !== 'string') continue
|
|
306
|
+
if (!(updatedAtOf(row) < cutoffMs)) continue
|
|
307
|
+
const month = monthOfRecord(row)
|
|
308
|
+
if (month === undefined) continue
|
|
309
|
+
let plan = byMonth.get(month)
|
|
310
|
+
if (plan === undefined) { plan = { entries: new Map(), bytes: 0 }; byMonth.set(month, plan) }
|
|
311
|
+
const json = JSON.stringify(row)
|
|
312
|
+
const size = Buffer.byteLength(json)
|
|
313
|
+
plan.entries.set(key, { row, json, bytes: size })
|
|
314
|
+
plan.bytes += size
|
|
315
|
+
planned += 1
|
|
316
|
+
plannedBytes += size
|
|
317
|
+
}
|
|
318
|
+
if (result.dryRun || byMonth.size === 0) {
|
|
319
|
+
result.months = Array.from(byMonth.keys()).sort()
|
|
320
|
+
result.archived = planned
|
|
321
|
+
result.bytes = plannedBytes
|
|
322
|
+
return result
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const manifest = await readManifest(fs, root)
|
|
326
|
+
for (const month of Array.from(byMonth.keys()).sort()) {
|
|
327
|
+
const plan = byMonth.get(month)
|
|
328
|
+
try {
|
|
329
|
+
const written = await archiveMonth({ root, fs, month, entries: plan.entries, manifest, store })
|
|
330
|
+
result.months.push(month)
|
|
331
|
+
result.archived += plan.entries.size
|
|
332
|
+
result.bytes += plan.bytes
|
|
333
|
+
result.compressed += written
|
|
334
|
+
} catch (error) {
|
|
335
|
+
// The tree still holds every record of this month: the run reports the
|
|
336
|
+
// failure and leaves the archive (if any) exactly as it found it.
|
|
337
|
+
result.errors.push({ month, message: error && error.message ? error.message : String(error) })
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return result
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// One month under the maintenance lock. Returns the bytes the month file grew by.
|
|
344
|
+
async function archiveMonth({ root, fs, month, entries, manifest, store }) {
|
|
345
|
+
const dir = join(root, ARCHIVE_DIR)
|
|
346
|
+
const recordPath = join(dir, month + RECORD_SUFFIX)
|
|
347
|
+
const keysPath = join(dir, month + KEYS_SUFFIX)
|
|
348
|
+
// Merge into what the month already holds instead of replacing it: a crashed run
|
|
349
|
+
// (or one that lost the race to delete a source) leaves rows this run must keep.
|
|
350
|
+
// The same session id keeps the newer row, so a retry can never roll a record
|
|
351
|
+
// back to an older revision.
|
|
352
|
+
const merged = await readArchiveMonth(fs, dir, month)
|
|
353
|
+
let changed = false
|
|
354
|
+
for (const [key, entry] of entries) {
|
|
355
|
+
const current = merged.get(key)
|
|
356
|
+
if (current !== undefined) {
|
|
357
|
+
if (updatedAtOf(current) > updatedAtOf(entry.row)) continue
|
|
358
|
+
if (JSON.stringify(current) === entry.json) continue
|
|
359
|
+
}
|
|
360
|
+
merged.set(key, entry.row)
|
|
361
|
+
changed = true
|
|
362
|
+
}
|
|
363
|
+
const keys = Array.from(merged.keys()).sort()
|
|
364
|
+
let packed = 0
|
|
365
|
+
if (changed) {
|
|
366
|
+
await fs.mkdir(dir, { recursive: true })
|
|
367
|
+
const lines = keys.map((key) => JSON.stringify(merged.get(key))).join('\n') + '\n'
|
|
368
|
+
const body = await gzip(Buffer.from(lines, 'utf8'))
|
|
369
|
+
await writeDurable(fs, recordPath, body)
|
|
370
|
+
await writeDurable(fs, keysPath, await gzip(Buffer.from(JSON.stringify(keys), 'utf8')))
|
|
371
|
+
packed = body.length
|
|
372
|
+
} else if (!(await fs.stat(keysPath).then(() => true, () => false))) {
|
|
373
|
+
// The rows are already archived; the fast key index behind listKeys() is not.
|
|
374
|
+
await fs.mkdir(dir, { recursive: true })
|
|
375
|
+
await writeDurable(fs, keysPath, await gzip(Buffer.from(JSON.stringify(keys), 'utf8')))
|
|
376
|
+
}
|
|
377
|
+
const size = changed ? packed : await fs.stat(recordPath).then((info) => info.size, () => 0)
|
|
378
|
+
const previous = manifest.months[month]
|
|
379
|
+
if (previous === undefined || previous.records !== keys.length || previous.bytes !== size) {
|
|
380
|
+
manifest.months[month] = { records: keys.length, bytes: size, updatedAt: Date.now() }
|
|
381
|
+
await writeManifest(fs, root, manifest)
|
|
382
|
+
}
|
|
383
|
+
// Record file, key index and manifest are on disk: only now is the tree copy
|
|
384
|
+
// redundant. A failure here leaves both, which the read path dedupes.
|
|
385
|
+
for (const key of entries.keys()) {
|
|
386
|
+
try {
|
|
387
|
+
// Optimistic delete: runtime writes do not hold the maintenance lock, so a
|
|
388
|
+
// session may have moved to a newer revision after this run planned it. The
|
|
389
|
+
// store removes the document only while it still holds the row archived
|
|
390
|
+
// here; a newer one stays in its tree (and wins on read) until the next run
|
|
391
|
+
// files that revision.
|
|
392
|
+
await store.deleteRecord(key, entries.get(key).row)
|
|
393
|
+
} catch (error) {
|
|
394
|
+
// Leaving the document behind is safe (the tree wins on read). It only means
|
|
395
|
+
// the next run sees the record again, so it must not abort the month.
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return packed
|
|
399
|
+
}
|