@remnic/core 10.0.93 → 10.0.94
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/dist/access-admin-ops-surface.js +29 -29
- package/dist/access-cli.js +29 -29
- package/dist/access-extraction-force-flush.js +29 -29
- package/dist/access-http-briefing.js +29 -29
- package/dist/access-http-query.js +29 -29
- package/dist/access-http.js +29 -29
- package/dist/access-identity-continuity-surface.js +29 -29
- package/dist/access-lcm-surface.js +29 -29
- package/dist/access-mcp.js +29 -29
- package/dist/access-observe-write-surface.js +29 -29
- package/dist/access-operations-batch.js +29 -29
- package/dist/access-operations.js +29 -29
- package/dist/access-recall-concurrency.js +29 -29
- package/dist/access-recall-response.js +29 -29
- package/dist/access-recall-surface.js +29 -29
- package/dist/access-service.js +29 -29
- package/dist/active-recall.js +2 -2
- package/dist/briefing.js +3 -3
- package/dist/causal-consolidation.js +4 -4
- package/dist/{chunk-2LMA22UU.js → chunk-4EZWSDFL.js} +3 -3
- package/dist/{chunk-PRAUN6PN.js → chunk-BN2EBCLB.js} +2 -2
- package/dist/{chunk-B33YWUJV.js → chunk-BPK32FRM.js} +2 -2
- package/dist/{chunk-NAQ4A5IX.js → chunk-CL4JU3DF.js} +3 -3
- package/dist/{chunk-YKJBSMMP.js → chunk-D5S6RL32.js} +24 -24
- package/dist/{chunk-BGHCSLE5.js → chunk-D5XG2KPS.js} +2 -2
- package/dist/{chunk-EFL24KOI.js → chunk-DNY63QWJ.js} +2 -2
- package/dist/{chunk-5YT2IIFX.js → chunk-FQAQ63QA.js} +2 -2
- package/dist/{chunk-WQURALGV.js → chunk-FWRXW7CZ.js} +7 -3
- package/dist/chunk-FWRXW7CZ.js.map +1 -0
- package/dist/{chunk-XIYLWEXC.js → chunk-FZZMLGWQ.js} +2 -2
- package/dist/{chunk-ZSE774RV.js → chunk-GBJUTX44.js} +2 -2
- package/dist/{chunk-ATG2PQ4V.js → chunk-IL3RHBWM.js} +3 -3
- package/dist/{chunk-OJXJWNFY.js → chunk-ILY5SSOM.js} +2 -2
- package/dist/{chunk-XRDYRWZI.js → chunk-IQ55QGY5.js} +2 -2
- package/dist/{chunk-BTI4C4MA.js → chunk-J7BIFOYP.js} +2 -2
- package/dist/{chunk-WKSQTMU7.js → chunk-LNGELD2M.js} +2 -2
- package/dist/{chunk-SGQPR5SX.js → chunk-OUVYYKYU.js} +2 -2
- package/dist/{chunk-CGJYL633.js → chunk-QHARXWTC.js} +2 -2
- package/dist/{chunk-SVT5IA2H.js → chunk-QMFAMOT2.js} +2 -2
- package/dist/{chunk-NT7F7WJQ.js → chunk-RFUL2ZIQ.js} +291 -93
- package/dist/chunk-RFUL2ZIQ.js.map +1 -0
- package/dist/{chunk-622V2AZA.js → chunk-RKABLU5M.js} +7 -7
- package/dist/{chunk-MH632KPO.js → chunk-RNG7S3FY.js} +2 -2
- package/dist/{chunk-GQXOBKPL.js → chunk-RUTN5MMC.js} +3 -3
- package/dist/{chunk-MUX7IL77.js → chunk-SUTEBYGI.js} +2 -2
- package/dist/{chunk-FMKILENU.js → chunk-WGGRZYSQ.js} +7 -7
- package/dist/{chunk-ETBVCRVL.js → chunk-X5HGU2EM.js} +2 -2
- package/dist/{chunk-TJ4F6H4H.js → chunk-XJ2S5UBF.js} +2 -2
- package/dist/{chunk-TRQE3RYR.js → chunk-YIXJSW5M.js} +2 -2
- package/dist/{chunk-3CYOCE6U.js → chunk-Z3FTSHT5.js} +2 -2
- package/dist/{chunk-RM2YUFH6.js → chunk-ZDV5YWID.js} +2 -2
- package/dist/cli.js +29 -29
- package/dist/coding/export-okf-codegraph.js +2 -2
- package/dist/compounding/engine.js +4 -4
- package/dist/config.js +2 -2
- package/dist/connectors/codex-materialize-runner.js +3 -3
- package/dist/connectors/index.js +3 -3
- package/dist/convergence-refresh.js +5 -5
- package/dist/corpus-watermark.js +5 -5
- package/dist/entity-retrieval.js +3 -3
- package/dist/explicit-capture.js +29 -29
- package/dist/extraction.js +3 -3
- package/dist/harmonic-retrieval.js +3 -3
- package/dist/index.js +29 -29
- package/dist/location/index.js +4 -4
- package/dist/maintenance/memory-governance.js +3 -3
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
- package/dist/maintenance/rebuild-memory-projection-runner.js +5 -5
- package/dist/maintenance/rebuild-memory-projection.js +4 -4
- package/dist/namespaces/migrate.js +4 -4
- package/dist/namespaces/storage.js +3 -3
- package/dist/operator-doctor-corpus.js +6 -6
- package/dist/operator-toolkit.js +9 -9
- package/dist/orchestration/backlog-scheduler.js +30 -30
- package/dist/orchestration/buffer-drain-runner.js +30 -30
- package/dist/orchestration/maintenance.js +11 -11
- package/dist/orchestrator.js +29 -29
- package/dist/recall-navigation.js +29 -29
- package/dist/resume-bundles.js +2 -2
- package/dist/semantic-consolidation.js +4 -4
- package/dist/semantic-rule-promotion.js +3 -3
- package/dist/semantic-rule-verifier.js +3 -3
- package/dist/source-agent-qualifier.js +3 -3
- package/dist/standup/build.js +3 -3
- package/dist/storage/buffer-drain-claims.d.ts +97 -12
- package/dist/storage/buffer-drain-claims.d.ts.map +1 -1
- package/dist/storage/buffer-drain-claims.js +1 -1
- package/dist/storage/buffer-session-files.d.ts +4 -0
- package/dist/storage/buffer-session-files.d.ts.map +1 -1
- package/dist/storage/buffer-session-files.js +1 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +2 -2
- package/dist/transfer/export-okf.js +3 -3
- package/dist/verified-recall.js +3 -3
- package/dist/who-knows-cli.js +4 -4
- package/dist/who-knows.js +3 -3
- package/package.json +2 -2
- package/src/buffer-drain-handover.test.ts +5 -1
- package/src/storage/buffer-drain-claims.test.ts +385 -1
- package/src/storage/buffer-drain-claims.ts +380 -97
- package/src/storage/buffer-session-files.ts +4 -0
- package/src/storage.ts +5 -1
- package/dist/chunk-NT7F7WJQ.js.map +0 -1
- package/dist/chunk-WQURALGV.js.map +0 -1
- /package/dist/{chunk-2LMA22UU.js.map → chunk-4EZWSDFL.js.map} +0 -0
- /package/dist/{chunk-PRAUN6PN.js.map → chunk-BN2EBCLB.js.map} +0 -0
- /package/dist/{chunk-B33YWUJV.js.map → chunk-BPK32FRM.js.map} +0 -0
- /package/dist/{chunk-NAQ4A5IX.js.map → chunk-CL4JU3DF.js.map} +0 -0
- /package/dist/{chunk-YKJBSMMP.js.map → chunk-D5S6RL32.js.map} +0 -0
- /package/dist/{chunk-BGHCSLE5.js.map → chunk-D5XG2KPS.js.map} +0 -0
- /package/dist/{chunk-EFL24KOI.js.map → chunk-DNY63QWJ.js.map} +0 -0
- /package/dist/{chunk-5YT2IIFX.js.map → chunk-FQAQ63QA.js.map} +0 -0
- /package/dist/{chunk-XIYLWEXC.js.map → chunk-FZZMLGWQ.js.map} +0 -0
- /package/dist/{chunk-ZSE774RV.js.map → chunk-GBJUTX44.js.map} +0 -0
- /package/dist/{chunk-ATG2PQ4V.js.map → chunk-IL3RHBWM.js.map} +0 -0
- /package/dist/{chunk-OJXJWNFY.js.map → chunk-ILY5SSOM.js.map} +0 -0
- /package/dist/{chunk-XRDYRWZI.js.map → chunk-IQ55QGY5.js.map} +0 -0
- /package/dist/{chunk-BTI4C4MA.js.map → chunk-J7BIFOYP.js.map} +0 -0
- /package/dist/{chunk-WKSQTMU7.js.map → chunk-LNGELD2M.js.map} +0 -0
- /package/dist/{chunk-SGQPR5SX.js.map → chunk-OUVYYKYU.js.map} +0 -0
- /package/dist/{chunk-CGJYL633.js.map → chunk-QHARXWTC.js.map} +0 -0
- /package/dist/{chunk-SVT5IA2H.js.map → chunk-QMFAMOT2.js.map} +0 -0
- /package/dist/{chunk-622V2AZA.js.map → chunk-RKABLU5M.js.map} +0 -0
- /package/dist/{chunk-MH632KPO.js.map → chunk-RNG7S3FY.js.map} +0 -0
- /package/dist/{chunk-GQXOBKPL.js.map → chunk-RUTN5MMC.js.map} +0 -0
- /package/dist/{chunk-MUX7IL77.js.map → chunk-SUTEBYGI.js.map} +0 -0
- /package/dist/{chunk-FMKILENU.js.map → chunk-WGGRZYSQ.js.map} +0 -0
- /package/dist/{chunk-ETBVCRVL.js.map → chunk-X5HGU2EM.js.map} +0 -0
- /package/dist/{chunk-TJ4F6H4H.js.map → chunk-XJ2S5UBF.js.map} +0 -0
- /package/dist/{chunk-TRQE3RYR.js.map → chunk-YIXJSW5M.js.map} +0 -0
- /package/dist/{chunk-3CYOCE6U.js.map → chunk-Z3FTSHT5.js.map} +0 -0
- /package/dist/{chunk-RM2YUFH6.js.map → chunk-ZDV5YWID.js.map} +0 -0
|
@@ -14,11 +14,31 @@
|
|
|
14
14
|
* s-<sha256(key)>.lease.json { ownerId, pid, acquiredAt, heartbeatAt,
|
|
15
15
|
* leaseMs } — created with exclusive-create so
|
|
16
16
|
* exactly one drainer holds a key.
|
|
17
|
+
* s-<sha256(key)>.json.inbox.jsonl
|
|
18
|
+
* append-only hand-ins from a live buffer owner,
|
|
19
|
+
* one `\n`-prefixed JSON record per line:
|
|
20
|
+
* { id, key, ...entry }. mergeIntoClaim only
|
|
21
|
+
* appends here (O(incoming)); the claim a reader
|
|
22
|
+
* sees is the body with every record folded in
|
|
23
|
+
* by the identity-aware merge. The drainer's
|
|
24
|
+
* progress write (and ack) folds the inbox into
|
|
25
|
+
* the body, lists the folded ids in the body's
|
|
26
|
+
* `foldedRecords`, and only then deletes the
|
|
27
|
+
* inbox, so a kill between the two replays
|
|
28
|
+
* nothing. A torn last line is skipped: its
|
|
29
|
+
* owner never saw the append succeed.
|
|
30
|
+
* s-<sha256(key)>.json.count cached { pending, drained } turn counts of the
|
|
31
|
+
* body, valid while the body's size and mtime
|
|
32
|
+
* match, so listings and the hand-over gate
|
|
33
|
+
* never parse a body.
|
|
34
|
+
*
|
|
35
|
+
* Rolling back to a build that predates the inbox: run `foldAllInboxes()` (CLI:
|
|
36
|
+
* `remnic buffer drain --fold-inboxes`) first; that build never reads an inbox.
|
|
17
37
|
*
|
|
18
38
|
* Invariants (see the buffer-drain PR for the full protocol):
|
|
19
|
-
* - At every instant the claim
|
|
20
|
-
* extraction is not durably finished, so a kill at any point
|
|
21
|
-
* nothing and the next run resumes.
|
|
39
|
+
* - At every instant the claim (body plus unfolded inbox) holds exactly the
|
|
40
|
+
* turns whose extraction is not durably finished, so a kill at any point
|
|
41
|
+
* loses nothing and the next run resumes.
|
|
22
42
|
* - A live buffer owner that must persist a claimed session hands its
|
|
23
43
|
* in-memory entry INTO the claim (identity-aware, occurrence-preserving
|
|
24
44
|
* merge) instead of writing a session file — it can never resurrect
|
|
@@ -28,19 +48,22 @@
|
|
|
28
48
|
* lease; the lock also fences lease takeover and progress writes).
|
|
29
49
|
*
|
|
30
50
|
* All content reads/writes go through the storage's secure file I/O
|
|
31
|
-
* (`BufferFileIo`); only lease/existence checks
|
|
32
|
-
* rewrites serialize on a cross-process
|
|
51
|
+
* (`BufferFileIo`); only lease/existence checks and the count summary touch
|
|
52
|
+
* raw paths, and claim rewrites and inbox appends serialize on a cross-process
|
|
53
|
+
* held file lock.
|
|
33
54
|
*/
|
|
34
55
|
|
|
56
|
+
import { randomUUID } from "node:crypto";
|
|
35
57
|
import { hostname } from "node:os";
|
|
36
58
|
import { z } from "zod";
|
|
37
59
|
import { existsSync } from "node:fs";
|
|
38
|
-
import { mkdir, readdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
60
|
+
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
39
61
|
import path from "node:path";
|
|
40
62
|
|
|
41
63
|
import { bufferSessionFileName, type BufferFileIo } from "./buffer-session-files.js";
|
|
42
64
|
import { bufferTurnIdentityKey, subtractBufferTurns, type DrainedTurnSets } from "../buffer-turn-helpers.js";
|
|
43
65
|
import { log } from "../logger.js";
|
|
66
|
+
import { isErrnoCode } from "../utils/errno.js";
|
|
44
67
|
import { withHeldFileLock } from "../utils/serialize-mutations.js";
|
|
45
68
|
import type { BufferEntryState, BufferTurn } from "../types.js";
|
|
46
69
|
|
|
@@ -83,6 +106,28 @@ export interface DrainClaimFile {
|
|
|
83
106
|
* the stale copy in the list the turn started in.
|
|
84
107
|
*/
|
|
85
108
|
movedTurns?: BufferTurn[];
|
|
109
|
+
/**
|
|
110
|
+
* Ids of the inbox records the latest compaction folded into this body. The
|
|
111
|
+
* inbox file is deleted AFTER the body write, so a kill in between leaves
|
|
112
|
+
* records that are already in the body; readers skip exactly these ids.
|
|
113
|
+
*/
|
|
114
|
+
foldedRecords?: string[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One hand-in appended to the claim's inbox by a live buffer owner. */
|
|
118
|
+
interface InboxRecord {
|
|
119
|
+
id: string;
|
|
120
|
+
entry: BufferEntryState;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A claim's turn counts, cached beside the body so listings never parse it. */
|
|
124
|
+
interface ClaimSummary {
|
|
125
|
+
key: string;
|
|
126
|
+
/** Identity of the body the counts describe; any rewrite invalidates them. */
|
|
127
|
+
size: number;
|
|
128
|
+
mtimeMs: number;
|
|
129
|
+
pending: number;
|
|
130
|
+
drained: number;
|
|
86
131
|
}
|
|
87
132
|
|
|
88
133
|
export type ClaimFromSessionOutcome = "claimed" | "empty" | "already_claimed";
|
|
@@ -113,17 +158,26 @@ function turnCounts(turns: readonly BufferTurn[]): Map<string, number> {
|
|
|
113
158
|
return counts;
|
|
114
159
|
}
|
|
115
160
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Append the incoming occurrences the claim does not hold yet. `counts` is the
|
|
163
|
+
* claim's per-identity occurrence count at the START of this merge (it is
|
|
164
|
+
* updated after, not during, so two equal incoming turns can both land) and
|
|
165
|
+
* stays in step across successive merges, so folding N records costs one pass
|
|
166
|
+
* over the claim instead of N.
|
|
167
|
+
*/
|
|
168
|
+
function appendNewOccurrences(target: BufferTurn[], counts: Map<string, number>, incoming: readonly BufferTurn[]): void {
|
|
119
169
|
const incomingCounts = new Map<string, number>();
|
|
170
|
+
const added: string[] = [];
|
|
120
171
|
for (const turn of incoming) {
|
|
121
172
|
const key = bufferTurnIdentityKey(turn);
|
|
122
173
|
const count = (incomingCounts.get(key) ?? 0) + 1;
|
|
123
174
|
incomingCounts.set(key, count);
|
|
124
|
-
if (count > (
|
|
175
|
+
if (count > (counts.get(key) ?? 0)) {
|
|
176
|
+
target.push(turn);
|
|
177
|
+
added.push(key);
|
|
178
|
+
}
|
|
125
179
|
}
|
|
126
|
-
|
|
180
|
+
for (const key of added) counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
127
181
|
}
|
|
128
182
|
|
|
129
183
|
function preserveSourceOccurrences(
|
|
@@ -165,24 +219,36 @@ function takeByIdentity(source: readonly BufferTurn[], identity: string, count:
|
|
|
165
219
|
return source.filter((turn) => bufferTurnIdentityKey(turn) === identity).slice(0, count);
|
|
166
220
|
}
|
|
167
221
|
|
|
168
|
-
|
|
222
|
+
/**
|
|
223
|
+
* The claim a reader sees: the body with every not-yet-compacted inbox record
|
|
224
|
+
* merged in order. A record whose id the body already lists (`foldedRecords`)
|
|
225
|
+
* was consumed by a compaction whose inbox delete never ran, so it is skipped
|
|
226
|
+
* exactly. The claim's own lists already exclude what was extracted; each
|
|
227
|
+
* tombstone applies only to the incoming copy in the list the turn was
|
|
228
|
+
* extracted from.
|
|
229
|
+
*/
|
|
230
|
+
function foldRecords(claim: DrainClaimFile, records: readonly InboxRecord[]): DrainClaimFile {
|
|
231
|
+
const consumed = new Set(claim.foldedRecords);
|
|
232
|
+
const fresh = records.filter((record) => !consumed.has(record.id));
|
|
233
|
+
if (fresh.length === 0) return claim;
|
|
234
|
+
const turns = [...claim.entry.turns];
|
|
235
|
+
const retainedTurns = [...(claim.entry.retainedTurns ?? [])];
|
|
236
|
+
const activeCounts = turnCounts(turns);
|
|
237
|
+
const retainedCounts = turnCounts(retainedTurns);
|
|
169
238
|
const entry = emptyEntry();
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
entry
|
|
173
|
-
|
|
174
|
-
subtractBufferTurns(incoming.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
);
|
|
184
|
-
entry.lastExtractionAt = stamps.sort().at(-1) ?? null;
|
|
185
|
-
entry.extractionCount = Math.max(claim.entry.extractionCount, incoming.extractionCount);
|
|
239
|
+
entry.lastExtractionAt = claim.entry.lastExtractionAt;
|
|
240
|
+
entry.extractionCount = claim.entry.extractionCount;
|
|
241
|
+
for (const { entry: incoming } of fresh) {
|
|
242
|
+
appendNewOccurrences(turns, activeCounts, subtractBufferTurns(incoming.turns, claim.drained ?? []));
|
|
243
|
+
appendNewOccurrences(retainedTurns, retainedCounts, subtractBufferTurns(incoming.retainedTurns ?? [], claim.drainedRetained ?? []));
|
|
244
|
+
const stamps = [entry.lastExtractionAt, incoming.lastExtractionAt].filter(
|
|
245
|
+
(value): value is string => value !== null && value !== undefined,
|
|
246
|
+
);
|
|
247
|
+
entry.lastExtractionAt = stamps.sort().at(-1) ?? null;
|
|
248
|
+
entry.extractionCount = Math.max(entry.extractionCount, incoming.extractionCount);
|
|
249
|
+
}
|
|
250
|
+
entry.turns = turns;
|
|
251
|
+
if (retainedTurns.length > 0) entry.retainedTurns = retainedTurns;
|
|
186
252
|
return { ...claim, entry };
|
|
187
253
|
}
|
|
188
254
|
|
|
@@ -224,6 +290,7 @@ const claimBodySchema = z.object({
|
|
|
224
290
|
drained: z.array(z.unknown()).optional(),
|
|
225
291
|
drainedRetained: z.array(z.unknown()).optional(),
|
|
226
292
|
movedTurns: z.array(z.unknown()).optional(),
|
|
293
|
+
foldedRecords: z.array(z.string()).optional(),
|
|
227
294
|
});
|
|
228
295
|
|
|
229
296
|
function parseBody(raw: string): DrainClaimFile | null {
|
|
@@ -269,9 +336,62 @@ function parseBody(raw: string): DrainClaimFile | null {
|
|
|
269
336
|
// SAFETY: same boundary as the turns above (this module writes the file).
|
|
270
337
|
claim.movedTurns = body.movedTurns as BufferTurn[];
|
|
271
338
|
}
|
|
339
|
+
if (body.foldedRecords) claim.foldedRecords = body.foldedRecords;
|
|
272
340
|
return claim;
|
|
273
341
|
}
|
|
274
342
|
|
|
343
|
+
const inboxRecordSchema = z.object({
|
|
344
|
+
id: z.string().min(1),
|
|
345
|
+
turns: z.array(z.unknown()),
|
|
346
|
+
retainedTurns: z.array(z.unknown()).optional(),
|
|
347
|
+
lastExtractionAt: z.string().nullable().optional(),
|
|
348
|
+
extractionCount: z.number().optional(),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Parse an inbox file: one JSON record per line. A record that does not parse
|
|
353
|
+
* (a torn tail from a kill mid-append) is skipped: its owner never saw the
|
|
354
|
+
* append succeed and keeps the save pending. Records are newline-PREFIXED, so
|
|
355
|
+
* a torn tail can never fuse with the next record.
|
|
356
|
+
*/
|
|
357
|
+
function parseInbox(raw: string): InboxRecord[] {
|
|
358
|
+
const records: InboxRecord[] = [];
|
|
359
|
+
for (const line of raw.split("\n")) {
|
|
360
|
+
if (line.length === 0) continue;
|
|
361
|
+
let record: z.infer<typeof inboxRecordSchema>;
|
|
362
|
+
try {
|
|
363
|
+
record = inboxRecordSchema.parse(JSON.parse(line));
|
|
364
|
+
} catch {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
// SAFETY: the inbox is written only by mergeIntoClaim from a BufferEntryState, same boundary as claim bodies.
|
|
368
|
+
const turns = record.turns as BufferTurn[];
|
|
369
|
+
const entry: BufferEntryState = {
|
|
370
|
+
turns,
|
|
371
|
+
lastExtractionAt: record.lastExtractionAt ?? null,
|
|
372
|
+
extractionCount: Number.isFinite(record.extractionCount) ? Number(record.extractionCount) : 0,
|
|
373
|
+
};
|
|
374
|
+
if (record.retainedTurns) {
|
|
375
|
+
// SAFETY: same boundary as the turns above.
|
|
376
|
+
entry.retainedTurns = record.retainedTurns as BufferTurn[];
|
|
377
|
+
}
|
|
378
|
+
records.push({ id: record.id, entry });
|
|
379
|
+
}
|
|
380
|
+
return records;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function pendingTurns(entry: BufferEntryState): number {
|
|
384
|
+
return entry.turns.length + (entry.retainedTurns?.length ?? 0);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const summarySchema = z.object({
|
|
388
|
+
key: z.string().min(1),
|
|
389
|
+
size: z.number(),
|
|
390
|
+
mtimeMs: z.number(),
|
|
391
|
+
pending: z.number(),
|
|
392
|
+
drained: z.number(),
|
|
393
|
+
});
|
|
394
|
+
|
|
275
395
|
const leaseSchema = z.object({
|
|
276
396
|
ownerId: z.string().min(1),
|
|
277
397
|
pid: z.number().optional(),
|
|
@@ -302,6 +422,11 @@ function leaseBelongsToProcess(lease: DrainLease | null, ownerId: string): boole
|
|
|
302
422
|
}
|
|
303
423
|
|
|
304
424
|
|
|
425
|
+
/** Hand-in log beside a claim body; `.jsonl`, so claim-directory scans (`.json`) never mistake it for a claim. */
|
|
426
|
+
const INBOX_SUFFIX = ".inbox.jsonl";
|
|
427
|
+
/** Cached turn counts beside a claim body (see `ClaimSummary`). */
|
|
428
|
+
const COUNT_SUFFIX = ".count";
|
|
429
|
+
|
|
305
430
|
/** Claim bodies `listPage` may read per requested entry: bounds one scan's
|
|
306
431
|
* I/O even when the directory is mostly empty tombstones. */
|
|
307
432
|
const LIST_PAGE_SCAN_FACTOR = 8;
|
|
@@ -328,6 +453,11 @@ export class BufferDrainClaims {
|
|
|
328
453
|
return `${this.fileFor(key)}.lock`;
|
|
329
454
|
}
|
|
330
455
|
|
|
456
|
+
/** Append-only hand-in log next to the body (see the file header). */
|
|
457
|
+
private inboxFor(key: string): string {
|
|
458
|
+
return `${this.fileFor(key)}${INBOX_SUFFIX}`;
|
|
459
|
+
}
|
|
460
|
+
|
|
331
461
|
/** Sync existence check for hot-path gates (`SmartBuffer.isSessionClaimed`). */
|
|
332
462
|
isClaimed(key: string): boolean {
|
|
333
463
|
return existsSync(this.fileFor(key));
|
|
@@ -346,14 +476,104 @@ export class BufferDrainClaims {
|
|
|
346
476
|
}
|
|
347
477
|
}
|
|
348
478
|
|
|
349
|
-
/**
|
|
350
|
-
|
|
351
|
-
|
|
479
|
+
/**
|
|
480
|
+
* Every inbox record, oldest first. Only "no inbox" reads as empty: any
|
|
481
|
+
* other failure (a locked or undecryptable store) propagates, because a
|
|
482
|
+
* compaction that mistook an unreadable inbox for an empty one would delete
|
|
483
|
+
* the handed-in turns.
|
|
484
|
+
*/
|
|
485
|
+
private async readInbox(key: string): Promise<InboxRecord[]> {
|
|
486
|
+
try {
|
|
487
|
+
return parseInbox(await this.io.read(this.inboxFor(key)));
|
|
488
|
+
} catch (err) {
|
|
489
|
+
if (isErrnoCode(err, "ENOENT")) return [];
|
|
490
|
+
throw err;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The body with its inbox folded in, plus the ids of every record the inbox
|
|
496
|
+
* holds (what a compaction must list as consumed). The inbox is read BEFORE
|
|
497
|
+
* the body: a compaction landing between the two reads leaves records the
|
|
498
|
+
* newer body already lists, which the fold skips by id, whereas the reverse
|
|
499
|
+
* order could pair an old body with a deleted inbox and lose their turns.
|
|
500
|
+
*/
|
|
501
|
+
private async readFolded(key: string): Promise<{ claim: DrainClaimFile; consumed: string[] } | null> {
|
|
502
|
+
const records = await this.readInbox(key);
|
|
503
|
+
const body = await this.readClaimBody(key);
|
|
504
|
+
if (!body) return null;
|
|
505
|
+
return { claim: foldRecords(body, records), consumed: records.map((record) => record.id) };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** The claim content for `key` (body plus inbox), or null when it is not claimed. */
|
|
509
|
+
async load(key: string): Promise<DrainClaimFile | null> {
|
|
510
|
+
return (await this.readFolded(key))?.claim ?? null;
|
|
352
511
|
}
|
|
353
512
|
|
|
354
|
-
|
|
513
|
+
/**
|
|
514
|
+
* Rewrite the body from a folded claim and consume the inbox it folded.
|
|
515
|
+
* Order is the crash contract: body first (it lists `consumed` so a replay
|
|
516
|
+
* skips them), then the summary, then the inbox delete. Callers hold the
|
|
517
|
+
* claim lock, so no append can land between the read and the delete.
|
|
518
|
+
*/
|
|
519
|
+
private async writeClaimBody(key: string, claim: DrainClaimFile, consumed: readonly string[] = []): Promise<void> {
|
|
355
520
|
await mkdir(this.dir, { recursive: true });
|
|
356
|
-
|
|
521
|
+
const bodyPath = this.fileFor(key);
|
|
522
|
+
const foldedRecords = consumed.length > 0 ? [...consumed] : undefined;
|
|
523
|
+
await this.io.write(bodyPath, JSON.stringify({ ...claim, key, foldedRecords }));
|
|
524
|
+
await stat(bodyPath).then((identity) => this.writeSummary(bodyPath, claim, identity), () => {});
|
|
525
|
+
await unlink(this.inboxFor(key)).catch(() => {});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Best-effort: a missing or stale summary only costs one body parse.
|
|
530
|
+
* `identity` MUST come from a stat taken before `claim` was read (or while
|
|
531
|
+
* the lock holds the body still), never after, or the counts could be
|
|
532
|
+
* tagged with a newer body's identity.
|
|
533
|
+
*/
|
|
534
|
+
private async writeSummary(bodyPath: string, claim: DrainClaimFile, identity: { size: number; mtimeMs: number }): Promise<ClaimSummary> {
|
|
535
|
+
const summary: ClaimSummary = {
|
|
536
|
+
key: claim.key,
|
|
537
|
+
size: identity.size,
|
|
538
|
+
mtimeMs: identity.mtimeMs,
|
|
539
|
+
pending: pendingTurns(claim.entry),
|
|
540
|
+
drained: (claim.drained?.length ?? 0) + (claim.drainedRetained?.length ?? 0),
|
|
541
|
+
};
|
|
542
|
+
try {
|
|
543
|
+
await writeFile(`${bodyPath}${COUNT_SUFFIX}`, JSON.stringify(summary));
|
|
544
|
+
} catch (err) {
|
|
545
|
+
log.debug(`buffer drain: claim summary write skipped for ${claim.key}: ${err instanceof Error ? err.message : String(err)}`);
|
|
546
|
+
}
|
|
547
|
+
return summary;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Turn counts of the BODY at `bodyPath` without parsing it: the summary file
|
|
552
|
+
* is trusted only while the body's size and mtime still match the ones it
|
|
553
|
+
* was written against; otherwise the body is parsed once and the summary
|
|
554
|
+
* refreshed. Null when the claim body is absent or unreadable.
|
|
555
|
+
*/
|
|
556
|
+
private async readSummary(bodyPath: string): Promise<ClaimSummary | null> {
|
|
557
|
+
let identity;
|
|
558
|
+
try {
|
|
559
|
+
identity = await stat(bodyPath);
|
|
560
|
+
} catch {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
const cached = summarySchema.parse(JSON.parse(await readFile(`${bodyPath}${COUNT_SUFFIX}`, "utf8")));
|
|
565
|
+
if (cached.size === identity.size && cached.mtimeMs === identity.mtimeMs) return cached;
|
|
566
|
+
} catch {
|
|
567
|
+
// No usable summary: fall through to the body.
|
|
568
|
+
}
|
|
569
|
+
let claim: DrainClaimFile | null;
|
|
570
|
+
try {
|
|
571
|
+
claim = parseBody(await this.io.read(bodyPath));
|
|
572
|
+
} catch (err) {
|
|
573
|
+
log.debug(`buffer drain: skipping unreadable claim file ${path.basename(bodyPath)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
return claim ? await this.writeSummary(bodyPath, claim, identity) : null;
|
|
357
577
|
}
|
|
358
578
|
|
|
359
579
|
private async readLease(key: string): Promise<DrainLease | null> {
|
|
@@ -405,6 +625,9 @@ export class BufferDrainClaims {
|
|
|
405
625
|
// keeps its content and its lease; the session file stays with its
|
|
406
626
|
// owner (their next save merges into the claim).
|
|
407
627
|
if (this.isClaimed(key)) return "already_claimed";
|
|
628
|
+
// No claim exists, so an inbox here is an orphan from a crash between
|
|
629
|
+
// a release's deletes: the new claim must not replay it.
|
|
630
|
+
await unlink(this.inboxFor(key)).catch(() => {});
|
|
408
631
|
try {
|
|
409
632
|
await rename(sessionPath, this.fileFor(key));
|
|
410
633
|
} catch (err) {
|
|
@@ -417,12 +640,13 @@ export class BufferDrainClaims {
|
|
|
417
640
|
throw err;
|
|
418
641
|
}
|
|
419
642
|
const claim = await this.readClaimBody(key);
|
|
420
|
-
if (!claim || (claim.entry
|
|
643
|
+
if (!claim || pendingTurns(claim.entry) === 0) {
|
|
421
644
|
// Empty content: nothing to drain. Delete the moved file so the
|
|
422
645
|
// empty entry keeps living with its owner, not as a stale claim.
|
|
423
646
|
await unlink(this.fileFor(key)).catch(() => {});
|
|
424
647
|
return "empty";
|
|
425
648
|
}
|
|
649
|
+
await stat(this.fileFor(key)).then((identity) => this.writeSummary(this.fileFor(key), claim, identity), () => {});
|
|
426
650
|
await this.writeLease(key, lease);
|
|
427
651
|
return "claimed";
|
|
428
652
|
},
|
|
@@ -472,9 +696,12 @@ export class BufferDrainClaims {
|
|
|
472
696
|
}
|
|
473
697
|
|
|
474
698
|
/**
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
699
|
+
* Hand a live owner's in-memory entry to the claim (the path a buffer owner
|
|
700
|
+
* takes instead of persisting a claimed session): ONE append to the inbox,
|
|
701
|
+
* O(incoming), never a read or rewrite of the claim body. Readers fold the
|
|
702
|
+
* inbox over the body with the identity-aware merge and the drainer's next
|
|
703
|
+
* progress write compacts it. The lock serializes the append against that
|
|
704
|
+
* compaction, so a record can never land between its read and its delete.
|
|
478
705
|
*/
|
|
479
706
|
async mergeIntoClaim(key: string, incoming: BufferEntryState): Promise<void> {
|
|
480
707
|
await withHeldFileLock(this.lockFileFor(key), { staleMs: CLAIM_LOCK_STALE_MS, maxWaitMs: CLAIM_LOCK_MAX_WAIT_MS }, async (acquired) => {
|
|
@@ -484,13 +711,12 @@ export class BufferDrainClaims {
|
|
|
484
711
|
// pending and retries (SmartBuffer keeps pending state on failure).
|
|
485
712
|
throw new Error(`buffer drain claim for ${key} is busy; retrying the save later`);
|
|
486
713
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
await this.writeClaimBody(key, mergeEntries(claim, incoming));
|
|
714
|
+
// The claim vanished (acked between the isClaimed check and now):
|
|
715
|
+
// nothing to hand over; the drain already owns everything.
|
|
716
|
+
if (!this.isClaimed(key)) return;
|
|
717
|
+
// Leading newline: a torn tail from a kill mid-append ends at this
|
|
718
|
+
// record's newline instead of fusing into it.
|
|
719
|
+
await this.io.append(this.inboxFor(key), `\n${JSON.stringify({ id: randomUUID(), key, ...incoming })}`);
|
|
494
720
|
});
|
|
495
721
|
}
|
|
496
722
|
|
|
@@ -515,8 +741,9 @@ export class BufferDrainClaims {
|
|
|
515
741
|
if (!leaseBelongsToProcess(lease, ownerId)) {
|
|
516
742
|
throw new Error(`buffer drain claim for ${key} is no longer owned by ${ownerId}; progress write skipped`);
|
|
517
743
|
}
|
|
518
|
-
const
|
|
519
|
-
if (!
|
|
744
|
+
const folded = await this.readFolded(key);
|
|
745
|
+
if (!folded) return;
|
|
746
|
+
const { claim, consumed } = folded;
|
|
520
747
|
const seenTurns = seen?.turns ?? claim.entry.turns;
|
|
521
748
|
const seenRetained = seen?.retainedTurns ?? claim.entry.retainedTurns ?? [];
|
|
522
749
|
const entryRetained = entry.retainedTurns ?? [];
|
|
@@ -600,7 +827,7 @@ export class BufferDrainClaims {
|
|
|
600
827
|
drained,
|
|
601
828
|
drainedRetained,
|
|
602
829
|
movedTurns: moved.length > 0 ? moved : undefined,
|
|
603
|
-
});
|
|
830
|
+
}, consumed);
|
|
604
831
|
});
|
|
605
832
|
}
|
|
606
833
|
|
|
@@ -644,12 +871,15 @@ export class BufferDrainClaims {
|
|
|
644
871
|
if (!acquired) return "busy";
|
|
645
872
|
const lease = await this.readLease(key);
|
|
646
873
|
if (!leaseBelongsToProcess(lease, ownerId)) return "busy";
|
|
647
|
-
const
|
|
648
|
-
if (!
|
|
874
|
+
const folded = await this.readFolded(key);
|
|
875
|
+
if (!folded) {
|
|
649
876
|
await unlink(this.leaseFileFor(key)).catch(() => {});
|
|
650
877
|
return "acked";
|
|
651
878
|
}
|
|
652
|
-
|
|
879
|
+
// Compact here too: a claim whose inbox only repeats drained turns
|
|
880
|
+
// would otherwise keep reading as unfinished forever.
|
|
881
|
+
if (folded.consumed.length > 0) await this.writeClaimBody(key, folded.claim, folded.consumed);
|
|
882
|
+
if (pendingTurns(folded.claim.entry) > 0) return "not_empty";
|
|
653
883
|
// Keep the empty claim as a tombstone until its live owner consumes it.
|
|
654
884
|
await unlink(this.leaseFileFor(key)).catch(() => {});
|
|
655
885
|
return "acked";
|
|
@@ -688,40 +918,72 @@ export class BufferDrainClaims {
|
|
|
688
918
|
* merge into it, never release it.
|
|
689
919
|
*/
|
|
690
920
|
async isDrained(key: string): Promise<boolean> {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
921
|
+
if ((await this.readLease(key)) !== null) return false;
|
|
922
|
+
// This runs on every hand-over save. The cached counts may only answer
|
|
923
|
+
// FALSE (pending turns, nothing drained): a stale cache in that direction
|
|
924
|
+
// costs a deferred tombstone. They must never answer TRUE, because the
|
|
925
|
+
// caller then releases the claim and unlinks whatever is still pending, and
|
|
926
|
+
// a same-size rewrite inside one mtime tick can leave a stale cache that
|
|
927
|
+
// says "nothing pending". So a TRUE is verified by one real fold, once per
|
|
928
|
+
// finished drain.
|
|
929
|
+
const summary = await this.readSummary(this.fileFor(key));
|
|
930
|
+
if (!summary || summary.drained === 0 || summary.pending > 0) return false;
|
|
931
|
+
const claim = await this.load(key);
|
|
932
|
+
return claim !== null && (claim.drained?.length ?? 0) + (claim.drainedRetained?.length ?? 0) > 0 && pendingTurns(claim.entry) === 0;
|
|
696
933
|
}
|
|
697
934
|
|
|
698
|
-
/** Drop the claim
|
|
935
|
+
/** Drop the claim entirely (its owner consumed the tombstone), inbox and summary included. */
|
|
699
936
|
async release(key: string): Promise<void> {
|
|
700
|
-
|
|
701
|
-
|
|
937
|
+
const bodyPath = this.fileFor(key);
|
|
938
|
+
for (const file of [this.inboxFor(key), bodyPath, `${bodyPath}${COUNT_SUFFIX}`, this.leaseFileFor(key)]) {
|
|
939
|
+
await unlink(file).catch(() => {});
|
|
940
|
+
}
|
|
702
941
|
}
|
|
703
942
|
|
|
704
|
-
/**
|
|
705
|
-
async
|
|
706
|
-
let names: string[];
|
|
943
|
+
/** Claim body file names in the drain directory (no leases, inboxes, summaries or locks). */
|
|
944
|
+
private async claimNames(): Promise<string[] | null> {
|
|
707
945
|
try {
|
|
708
|
-
|
|
946
|
+
return (await readdir(this.dir)).filter((name) => name.endsWith(".json") && !name.endsWith(".lease.json")).sort();
|
|
709
947
|
} catch (err) {
|
|
710
|
-
// SAFETY:
|
|
711
|
-
|
|
948
|
+
// SAFETY: readdir rejects with NodeJS system errors; only the ENOENT
|
|
949
|
+
// (no claims directory yet) case maps to an empty listing.
|
|
950
|
+
if ((err as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return null;
|
|
712
951
|
throw err;
|
|
713
952
|
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* One claim's listing from its summary plus its inbox: the body is never
|
|
957
|
+
* parsed for a count. While an inbox is unfolded the turn count is an UPPER
|
|
958
|
+
* bound (records can repeat turns the body holds or already drained); the
|
|
959
|
+
* drain that picks the claim up folds exactly and its ack compacts the
|
|
960
|
+
* inbox, so an over-count costs one cheap drain run, never a lost turn.
|
|
961
|
+
*/
|
|
962
|
+
private async describeClaim(name: string): Promise<DrainClaimListing | null> {
|
|
963
|
+
const summary = await this.readSummary(path.join(this.dir, name));
|
|
964
|
+
if (!summary) return null;
|
|
965
|
+
let inboxTurns = 0;
|
|
966
|
+
try {
|
|
967
|
+
for (const record of await this.readInbox(summary.key)) inboxTurns += pendingTurns(record.entry);
|
|
968
|
+
} catch (err) {
|
|
969
|
+
log.debug(`buffer drain: skipping claim ${name} with an unreadable inbox: ${err instanceof Error ? err.message : String(err)}`);
|
|
970
|
+
return null;
|
|
971
|
+
}
|
|
972
|
+
const lease = await this.readLease(summary.key);
|
|
973
|
+
return {
|
|
974
|
+
key: summary.key,
|
|
975
|
+
turnCount: summary.pending + inboxTurns,
|
|
976
|
+
leased: lease !== null,
|
|
977
|
+
leaseExpired: lease !== null ? isLeaseExpired(lease) : true,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Every claim with its lease state (CLI scan + summaries). */
|
|
982
|
+
async list(): Promise<DrainClaimListing[]> {
|
|
714
983
|
const out: DrainClaimListing[] = [];
|
|
715
|
-
for (const name of
|
|
716
|
-
const
|
|
717
|
-
if (
|
|
718
|
-
const lease = await this.readLease(claim.key);
|
|
719
|
-
out.push({
|
|
720
|
-
key: claim.key,
|
|
721
|
-
turnCount: claim.entry.turns.length + (claim.entry.retainedTurns?.length ?? 0),
|
|
722
|
-
leased: lease !== null,
|
|
723
|
-
leaseExpired: lease !== null ? isLeaseExpired(lease) : true,
|
|
724
|
-
});
|
|
984
|
+
for (const name of (await this.claimNames()) ?? []) {
|
|
985
|
+
const listing = await this.describeClaim(name);
|
|
986
|
+
if (listing) out.push(listing);
|
|
725
987
|
}
|
|
726
988
|
return out;
|
|
727
989
|
}
|
|
@@ -732,22 +994,14 @@ export class BufferDrainClaims {
|
|
|
732
994
|
* `nextCursor` by a previous page — file-name order is stable, so a
|
|
733
995
|
* rotating cursor visits every claim fairly without ever materializing
|
|
734
996
|
* the whole directory's bodies). Empty tombstones never count toward the
|
|
735
|
-
* limit; the number of
|
|
997
|
+
* limit; the number of claims examined per call is bounded separately
|
|
736
998
|
* (`LIST_PAGE_SCAN_FACTOR` per requested entry), and the cursor advances
|
|
737
999
|
* past every scanned name so a long tombstone run is crossed over several
|
|
738
1000
|
* pages instead of starving the pending claims behind it.
|
|
739
1001
|
*/
|
|
740
1002
|
async listPage(afterName: string | undefined, limit: number): Promise<{ entries: DrainClaimListing[]; nextCursor?: string }> {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
names = (await readdir(this.dir)).filter((name) => name.endsWith(".json") && !name.endsWith(".lease.json")).sort();
|
|
744
|
-
} catch (err) {
|
|
745
|
-
// SAFETY: readdir rejects with NodeJS system errors; only the ENOENT
|
|
746
|
-
// (no claims directory yet) case maps to an empty listing.
|
|
747
|
-
const errno = (err as NodeJS.ErrnoException | undefined)?.code;
|
|
748
|
-
if (errno === "ENOENT") return { entries: [] };
|
|
749
|
-
throw err;
|
|
750
|
-
}
|
|
1003
|
+
const names = await this.claimNames();
|
|
1004
|
+
if (names === null) return { entries: [] };
|
|
751
1005
|
const start = afterName === undefined ? 0 : names.findIndex((name) => name > afterName);
|
|
752
1006
|
const out: DrainClaimListing[] = [];
|
|
753
1007
|
// The cursor is the LAST CONSUMED entry name: the next page starts
|
|
@@ -762,22 +1016,51 @@ export class BufferDrainClaims {
|
|
|
762
1016
|
const name = names[index]!;
|
|
763
1017
|
scanned += 1;
|
|
764
1018
|
cursor = name;
|
|
765
|
-
const
|
|
766
|
-
if (
|
|
767
|
-
const turnCount = claim.entry.turns.length + (claim.entry.retainedTurns?.length ?? 0);
|
|
768
|
-
if (turnCount <= 0) continue;
|
|
769
|
-
const lease = await this.readLease(claim.key);
|
|
770
|
-
out.push({
|
|
771
|
-
key: claim.key,
|
|
772
|
-
turnCount,
|
|
773
|
-
leased: lease !== null,
|
|
774
|
-
leaseExpired: lease !== null ? isLeaseExpired(lease) : true,
|
|
775
|
-
});
|
|
1019
|
+
const listing = await this.describeClaim(name);
|
|
1020
|
+
if (listing && listing.turnCount > 0) out.push(listing);
|
|
776
1021
|
}
|
|
777
1022
|
return { entries: out, nextCursor: cursor };
|
|
778
1023
|
}
|
|
779
1024
|
|
|
780
|
-
|
|
1025
|
+
/**
|
|
1026
|
+
* Rollback helper: fold every claim's inbox into its body and delete the
|
|
1027
|
+
* inboxes, so a build that predates the inbox (it never reads one) sees every
|
|
1028
|
+
* handed-in turn. Run it with the daemon stopped, BEFORE downgrading; the
|
|
1029
|
+
* same fold is what compaction does, so it is safe to run on a live store.
|
|
1030
|
+
* Returns how many inboxes were folded.
|
|
1031
|
+
*/
|
|
1032
|
+
async foldAllInboxes(): Promise<number> {
|
|
1033
|
+
let names: string[];
|
|
1034
|
+
try {
|
|
1035
|
+
names = (await readdir(this.dir)).filter((name) => name.endsWith(INBOX_SUFFIX));
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
if (isErrnoCode(err, "ENOENT")) return 0;
|
|
1038
|
+
throw err;
|
|
1039
|
+
}
|
|
1040
|
+
let folded = 0;
|
|
1041
|
+
for (const name of names) {
|
|
1042
|
+
const claim = await this.readClaimBodyByName(name.slice(0, -INBOX_SUFFIX.length));
|
|
1043
|
+
if (!claim) continue;
|
|
1044
|
+
folded += await withHeldFileLock(
|
|
1045
|
+
this.lockFileFor(claim.key),
|
|
1046
|
+
{ staleMs: CLAIM_LOCK_STALE_MS, maxWaitMs: CLAIM_LOCK_MAX_WAIT_MS },
|
|
1047
|
+
async (acquired) => {
|
|
1048
|
+
if (!acquired) throw new Error(`buffer drain claim for ${claim.key} is busy; its inbox was not folded`);
|
|
1049
|
+
const current = await this.readFolded(claim.key);
|
|
1050
|
+
if (!current) return 0;
|
|
1051
|
+
if (current.consumed.length === 0) {
|
|
1052
|
+
await unlink(this.inboxFor(claim.key)).catch(() => {});
|
|
1053
|
+
return 0;
|
|
1054
|
+
}
|
|
1055
|
+
await this.writeClaimBody(claim.key, current.claim, current.consumed);
|
|
1056
|
+
return 1;
|
|
1057
|
+
},
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
return folded;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
private async readClaimBodyByName(name: string): Promise<DrainClaimFile | null> {
|
|
781
1064
|
try {
|
|
782
1065
|
return parseBody(await this.io.read(path.join(this.dir, name)));
|
|
783
1066
|
} catch (err) {
|