@dogfood-lab/ingest 1.3.2 → 1.5.0
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/README.md +42 -22
- package/anchor/cli.js +185 -0
- package/anchor/compute-root.js +302 -0
- package/anchor/config.js +44 -0
- package/anchor/merkle.js +123 -0
- package/anchor/post-anchor.js +299 -0
- package/anchor/verify-anchor.js +358 -0
- package/lib/chain-manifest.js +106 -0
- package/lib/integrity.js +98 -0
- package/package.json +6 -2
- package/persist.js +41 -1
- package/rebuild-indexes.js +262 -20
- package/run.js +239 -25
- package/verify-chain.js +178 -0
package/rebuild-indexes.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Regenerated on every accepted/rejected write in Phase 1.
|
|
10
10
|
*
|
|
11
|
-
* Multi-file commit-group
|
|
11
|
+
* Multi-file commit-group: crash/IO-failure RECOVERY-atomic, NOT reader-atomic
|
|
12
|
+
* (W3-PIPE-002):
|
|
12
13
|
* The 3 indexes are written together via a two-phase commit pattern. Phase 1
|
|
13
14
|
* stages all 3 files to temp paths AND records them in a journal file. Phase 2
|
|
14
15
|
* renames each temp into its final location, then deletes the journal. If the
|
|
@@ -17,15 +18,32 @@
|
|
|
17
18
|
* is idempotent (it scans records/ end-to-end), so re-running is the correct
|
|
18
19
|
* recovery action.
|
|
19
20
|
*
|
|
20
|
-
*
|
|
21
|
+
* IMPORTANT — what "atomicity" means here. The guarantee is RECOVERY-atomic,
|
|
22
|
+
* not READER-atomic. Phase 2 promotes the temps with a per-leg `renameSync`
|
|
23
|
+
* (each rename is individually atomic), but the GROUP is not promoted under a
|
|
24
|
+
* single atomic operation. During the promote window — and during the heal
|
|
25
|
+
* window after a mid-promote IO failure (ENOSPC/EACCES after the first
|
|
26
|
+
* final is renamed but a later one is not) — a concurrent reader CAN observe
|
|
27
|
+
* the index group in a mutually-inconsistent intermediate state (e.g. an
|
|
28
|
+
* already-promoted latest-by-repo.json against a not-yet-promoted failing.json).
|
|
29
|
+
* The catch on a promote failure does NOT roll back already-promoted finals;
|
|
30
|
+
* it preserves the journal and emits a structured error event so an operator
|
|
31
|
+
* can force an immediate rebuild before the next scheduled run heals it. The
|
|
32
|
+
* design is sound because the only writer (the ingest pipeline) serializes
|
|
33
|
+
* rebuilds and `rebuildIndexes` is synchronous — there is no in-flight reader
|
|
34
|
+
* that races a writer mid-promote within a single process. If you ever need
|
|
35
|
+
* true reader-atomicity (a reader that NEVER sees a torn group), this design
|
|
36
|
+
* must change (e.g. swap a single directory symlink, or version the index dir).
|
|
37
|
+
*
|
|
38
|
+
* Pattern reference: choke-point fix (Pattern #4) for multi-file recovery.
|
|
21
39
|
* Single-file `atomicWriteFileSync` (lib/atomic-write.js) handles each leg;
|
|
22
|
-
* the journal handles the cross-file boundary. The single-file helper
|
|
23
|
-
* the same one Class #6 helper-adoption-sweep enforces as canonical for
|
|
40
|
+
* the journal handles the cross-file recovery boundary. The single-file helper
|
|
41
|
+
* is the same one Class #6 helper-adoption-sweep enforces as canonical for
|
|
24
42
|
* temp+rename writes under `packages/ingest/`.
|
|
25
43
|
*/
|
|
26
44
|
|
|
27
45
|
import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
|
28
|
-
import { join, relative } from 'node:path';
|
|
46
|
+
import { join, relative, sep } from 'node:path';
|
|
29
47
|
import { randomBytes } from 'node:crypto';
|
|
30
48
|
|
|
31
49
|
import { logStage as sharedLogStage } from '@dogfood-lab/dogfood-swarm/lib/log-stage.js';
|
|
@@ -49,14 +67,44 @@ function logStage(stage, fields = {}) {
|
|
|
49
67
|
/**
|
|
50
68
|
* Recursively find all .json files under a directory.
|
|
51
69
|
*
|
|
70
|
+
* d3-ingest-B005 (Stage C humanization): the `readdirSync` is wrapped so an
|
|
71
|
+
* unreadable subtree (EACCES, ENOTDIR, a Windows lock — the same class
|
|
72
|
+
* renameWithRetry defends against) degrades to "that subtree's records are
|
|
73
|
+
* missing from this rebuild" instead of throwing and aborting the WHOLE scan.
|
|
74
|
+
* This restores parity with the per-FILE tolerance of `loadRecord` just below
|
|
75
|
+
* (module header: "does NOT crash on a single bad file") and mirrors the
|
|
76
|
+
* in-repo precedent in `packages/portfolio/lib/parse-regression-pins.js`'s
|
|
77
|
+
* `walkSourceFiles`. The skip is NOT silent: a structured
|
|
78
|
+
* `logStage('warn', { kind: 'dir_unreadable', path })` NDJSON line makes it
|
|
79
|
+
* greppable alongside the rest of the pipeline (`"kind":"dir_unreadable"`), so
|
|
80
|
+
* the operator can see which subtree was dropped and why the index is partial.
|
|
81
|
+
* Exported so the guard is unit-testable in isolation (the single wrapped
|
|
82
|
+
* `readdirSync` serves both the top-level call and every recursive descent).
|
|
83
|
+
*
|
|
52
84
|
* @param {string} dir
|
|
53
85
|
* @returns {string[]}
|
|
54
86
|
*/
|
|
55
|
-
function findJsonFiles(dir) {
|
|
87
|
+
export function findJsonFiles(dir) {
|
|
56
88
|
const results = [];
|
|
57
89
|
if (!existsSync(dir)) return results;
|
|
58
90
|
|
|
59
|
-
|
|
91
|
+
let entries;
|
|
92
|
+
try {
|
|
93
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// Unreadable directory (locked / permission-restricted / not a dir).
|
|
96
|
+
// Skip this subtree rather than sinking the whole rebuild, but emit a
|
|
97
|
+
// structured, greppable warn naming the path so the partial index is
|
|
98
|
+
// explained — not a silent swallow.
|
|
99
|
+
logStage('warn', {
|
|
100
|
+
kind: 'dir_unreadable',
|
|
101
|
+
reason: err && err.code ? err.code : 'readdir_failed',
|
|
102
|
+
path: dir,
|
|
103
|
+
error: err && err.message ? err.message : String(err),
|
|
104
|
+
});
|
|
105
|
+
return results;
|
|
106
|
+
}
|
|
107
|
+
|
|
60
108
|
for (const entry of entries) {
|
|
61
109
|
const fullPath = join(dir, entry.name);
|
|
62
110
|
if (entry.isDirectory()) {
|
|
@@ -68,6 +116,72 @@ function findJsonFiles(dir) {
|
|
|
68
116
|
return results;
|
|
69
117
|
}
|
|
70
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Probe whether `dir` exists but is unreadable at its OWN level (EACCES /
|
|
121
|
+
* Windows lock / ENOTDIR) — as distinct from a deep leaf failing mid-walk.
|
|
122
|
+
*
|
|
123
|
+
* ingest-B-002: `findJsonFiles` deliberately degrades an unreadable subtree to
|
|
124
|
+
* "those records are missing" and returns `[]`. That is correct for a single
|
|
125
|
+
* locked LEAF, but catastrophic for the records/ ROOT: a transiently-locked
|
|
126
|
+
* root makes the WHOLE corpus invisible, and an unguarded rebuild would then
|
|
127
|
+
* overwrite every index with empty content. This probe lets `rebuildIndexes`
|
|
128
|
+
* tell the two apart so it can REFUSE to clobber good indexes when the root
|
|
129
|
+
* itself is the thing that failed. A non-existent dir is NOT unreadable — that
|
|
130
|
+
* is the legitimate empty-corpus case, which must still rebuild empty indexes.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} dir
|
|
133
|
+
* @returns {{ unreadable: boolean, code: string|null, error: string|null }}
|
|
134
|
+
*/
|
|
135
|
+
function probeDirReadable(dir) {
|
|
136
|
+
if (!existsSync(dir)) return { unreadable: false, code: null, error: null };
|
|
137
|
+
try {
|
|
138
|
+
readdirSync(dir);
|
|
139
|
+
return { unreadable: false, code: null, error: null };
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return {
|
|
142
|
+
unreadable: true,
|
|
143
|
+
code: err && err.code ? err.code : 'readdir_failed',
|
|
144
|
+
error: err && err.message ? err.message : String(err),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Read the prior committed latest-by-repo.json so a rebuild can tell whether
|
|
151
|
+
* the index it is about to overwrite currently has content. Used by the
|
|
152
|
+
* ingest-B-002 refuse-to-overwrite guard: an empty scan is only suspicious if
|
|
153
|
+
* the prior index was non-empty. A missing or unparseable prior index counts
|
|
154
|
+
* as "no prior content" (the legitimate first-run / empty-corpus case).
|
|
155
|
+
*
|
|
156
|
+
* @param {string} latestPath
|
|
157
|
+
* @returns {boolean} true if the prior index existed and held at least one repo
|
|
158
|
+
*/
|
|
159
|
+
function priorIndexHasContent(latestPath) {
|
|
160
|
+
if (!existsSync(latestPath)) return false;
|
|
161
|
+
try {
|
|
162
|
+
const prior = JSON.parse(readFileSync(latestPath, 'utf-8'));
|
|
163
|
+
return prior && typeof prior === 'object' && Object.keys(prior).length > 0;
|
|
164
|
+
} catch {
|
|
165
|
+
// Unparseable prior index — treat as no usable content so a corrupt index
|
|
166
|
+
// never wedges the rebuild into a permanent refuse state.
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Whether the freshly-built latest-by-repo map has no repos. Used by the
|
|
173
|
+
* ingest-B-002 refuse-to-overwrite guard to recognise an empty scan. A scan
|
|
174
|
+
* can be empty because there are genuinely no accepted records (legitimate)
|
|
175
|
+
* or because the corpus was invisible (a transiently-locked records tree) —
|
|
176
|
+
* the guard combines this with `priorIndexHasContent` to tell them apart.
|
|
177
|
+
*
|
|
178
|
+
* @param {object} latestByRepo
|
|
179
|
+
* @returns {boolean}
|
|
180
|
+
*/
|
|
181
|
+
function latestByRepoIsEmpty(latestByRepo) {
|
|
182
|
+
return !latestByRepo || Object.keys(latestByRepo).length === 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
71
185
|
/**
|
|
72
186
|
* Load and parse a record file.
|
|
73
187
|
*
|
|
@@ -101,8 +215,20 @@ export function rebuildIndexes(repoRoot, options = {}) {
|
|
|
101
215
|
const indexDir = join(repoRoot, 'indexes');
|
|
102
216
|
mkdirSync(indexDir, { recursive: true });
|
|
103
217
|
|
|
104
|
-
//
|
|
218
|
+
// ingest-B-002: capture two facts BEFORE the scan so we can refuse to clobber
|
|
219
|
+
// good indexes with empty ones when the corpus is invisible rather than empty.
|
|
220
|
+
// 1. Is the records/ ROOT itself unreadable (vs a deep leaf, vs absent)?
|
|
221
|
+
// A locked root makes the WHOLE corpus invisible — findJsonFiles would
|
|
222
|
+
// return [] after one low `dir_unreadable` warn, and an unguarded
|
|
223
|
+
// commit-group would then overwrite every index with {}.
|
|
224
|
+
// 2. Did the PRIOR latest-by-repo.json have content? An empty scan is only
|
|
225
|
+
// suspicious if there was something to lose; a legitimately empty
|
|
226
|
+
// first-run corpus must still write empty indexes.
|
|
105
227
|
const recordsDir = join(repoRoot, 'records');
|
|
228
|
+
const latestPath = join(indexDir, 'latest-by-repo.json');
|
|
229
|
+
const rootProbe = probeDirReadable(recordsDir);
|
|
230
|
+
const hadPriorIndex = priorIndexHasContent(latestPath);
|
|
231
|
+
|
|
106
232
|
const acceptedFiles = findJsonFiles(recordsDir)
|
|
107
233
|
.filter(f => {
|
|
108
234
|
const rel = relative(recordsDir, f);
|
|
@@ -115,7 +241,17 @@ export function rebuildIndexes(repoRoot, options = {}) {
|
|
|
115
241
|
const skipped = [];
|
|
116
242
|
|
|
117
243
|
for (const f of [...acceptedFiles, ...rejectedFiles]) {
|
|
118
|
-
|
|
244
|
+
// SEED-1 (d3-ingest-001) — posixify at the serialization boundary.
|
|
245
|
+
// `relative()` returns OS-native separators (backslashes on win32). This
|
|
246
|
+
// value becomes `record._path` and is serialized verbatim into all three
|
|
247
|
+
// committed index `path` fields (latest-by-repo / failing / stale), which
|
|
248
|
+
// downstream consumers read as raw.githubusercontent.com URL fragments
|
|
249
|
+
// (docs/policy-contract.md Gate F) — a backslash there is a broken URL.
|
|
250
|
+
// Normalize ONCE here, at the single source the whole family flows through,
|
|
251
|
+
// so every serialized path is forward-slash regardless of host OS. Mirrors
|
|
252
|
+
// the canonical transform proven in
|
|
253
|
+
// packages/portfolio/lib/parse-regression-pins.js:75. NEVER a win32-skip.
|
|
254
|
+
const relPath = relative(repoRoot, f).split(sep).join('/');
|
|
119
255
|
const { record, error } = loadRecord(f);
|
|
120
256
|
if (error) {
|
|
121
257
|
corrupted.push({ path: relPath, error });
|
|
@@ -228,9 +364,49 @@ export function rebuildIndexes(repoRoot, options = {}) {
|
|
|
228
364
|
}
|
|
229
365
|
}
|
|
230
366
|
|
|
367
|
+
// ingest-B-002: REFUSE to overwrite good indexes with empty ones when the
|
|
368
|
+
// corpus was invisible rather than genuinely empty. Two refuse conditions:
|
|
369
|
+
// - records_root_unreadable: the records/ ROOT itself failed to read
|
|
370
|
+
// (EACCES / Windows lock / ENOTDIR). The entire corpus is invisible —
|
|
371
|
+
// committing now would wipe every index. This is distinct from a single
|
|
372
|
+
// locked leaf, which findJsonFiles already degrades to a partial scan.
|
|
373
|
+
// - empty_scan_with_prior_index: the root read fine but the scan found
|
|
374
|
+
// zero accepted records while the prior latest-by-repo had content. The
|
|
375
|
+
// records likely vanished transiently; clobbering loses the portfolio.
|
|
376
|
+
// A legitimately empty corpus (no accepted records AND no prior content) is
|
|
377
|
+
// NOT refused — it must still write empty indexes (first-run case). We skip
|
|
378
|
+
// the commit-group and emit a structured, greppable event so the operator
|
|
379
|
+
// sees the refusal loudly instead of a silently-emptied portfolio.
|
|
380
|
+
const noAcceptedScanned = latestByRepoIsEmpty(latestByRepo);
|
|
381
|
+
if (rootProbe.unreadable || (noAcceptedScanned && hadPriorIndex)) {
|
|
382
|
+
const reason = rootProbe.unreadable
|
|
383
|
+
? 'records_root_unreadable'
|
|
384
|
+
: 'empty_scan_with_prior_index';
|
|
385
|
+
// A root IO failure is an operator-actionable error (the corpus is gone);
|
|
386
|
+
// an empty scan over a readable root is a warn (recoverable next run).
|
|
387
|
+
logStage(rootProbe.unreadable ? 'error' : 'warn', {
|
|
388
|
+
kind: 'index_rebuild_skipped',
|
|
389
|
+
reason,
|
|
390
|
+
records_dir: recordsDir,
|
|
391
|
+
accepted_scanned: acceptedFiles.length,
|
|
392
|
+
prior_index_non_empty: hadPriorIndex,
|
|
393
|
+
root_error_code: rootProbe.code,
|
|
394
|
+
error: rootProbe.error,
|
|
395
|
+
});
|
|
396
|
+
return {
|
|
397
|
+
latestByRepo,
|
|
398
|
+
failing,
|
|
399
|
+
stale,
|
|
400
|
+
accepted: acceptedFiles.length,
|
|
401
|
+
rejected: rejectedFiles.length,
|
|
402
|
+
corrupted,
|
|
403
|
+
skipped,
|
|
404
|
+
skippedCommit: reason,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
231
408
|
// Write indexes via commit-group two-phase commit. See module header
|
|
232
409
|
// for the full design rationale.
|
|
233
|
-
const latestPath = join(indexDir, 'latest-by-repo.json');
|
|
234
410
|
const failingPath = join(indexDir, 'failing.json');
|
|
235
411
|
const stalePath = join(indexDir, 'stale.json');
|
|
236
412
|
|
|
@@ -267,15 +443,23 @@ export function rebuildIndexes(repoRoot, options = {}) {
|
|
|
267
443
|
* AND records them in a journal first; then renames them in caller-given
|
|
268
444
|
* order. The journal is deleted only after every rename succeeds.
|
|
269
445
|
*
|
|
270
|
-
* Crash semantics:
|
|
271
|
-
* -
|
|
446
|
+
* Crash / IO-failure semantics (RECOVERY-atomic, not reader-atomic):
|
|
447
|
+
* - Failure during STAGE phase: every staged temp is unlinked in the catch
|
|
272
448
|
* block; the journal (if written) is unlinked too. No partial visible
|
|
273
|
-
* state.
|
|
274
|
-
* -
|
|
275
|
-
* final path; remaining temps are still next to
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
449
|
+
* state — no final was touched.
|
|
450
|
+
* - Failure during PROMOTE phase: any successfully-renamed file is at its
|
|
451
|
+
* final path with its NEW content; remaining temps are still next to
|
|
452
|
+
* their (still-OLD) finals. The group is therefore mutually inconsistent
|
|
453
|
+
* until healed — a reader in this window sees a torn group. We do NOT
|
|
454
|
+
* roll back the already-promoted finals (their prior content was already
|
|
455
|
+
* overwritten by the atomic rename — there is nothing to roll back to
|
|
456
|
+
* without re-reading the journal). Instead we emit a structured
|
|
457
|
+
* `logStage('error', { kind: 'commit_group_partial_promote', ... })`
|
|
458
|
+
* naming which finals were promoted vs left stale so an operator can
|
|
459
|
+
* force an immediate rebuild, and we preserve the journal. Next run's
|
|
460
|
+
* `cleanupCrashedJournals` deletes residual temps and the journal; the
|
|
461
|
+
* next normal `rebuildIndexes` call rewrites all 3 indexes from scratch
|
|
462
|
+
* (idempotent), which is what heals the torn group.
|
|
279
463
|
*
|
|
280
464
|
* Why journal-then-rename rather than journal-only: the rename phase needs
|
|
281
465
|
* to be the visible commit point. A journal-only design would require
|
|
@@ -299,8 +483,12 @@ function commitGroupRename(indexDir, entries) {
|
|
|
299
483
|
|
|
300
484
|
// Write journal AFTER staging so it never points at a non-existent temp.
|
|
301
485
|
// Atomic write of the journal itself: writeFileSync directly is fine here
|
|
302
|
-
// because the journal is process-private
|
|
303
|
-
// collision
|
|
486
|
+
// because the journal is process-private — the pid + random suffix make
|
|
487
|
+
// the filename collision-free, and `cleanupCrashedJournals` is pid-aware
|
|
488
|
+
// (it skips journals whose pid is a still-live process), so a future
|
|
489
|
+
// concurrent rebuild's in-flight journal is never reaped out from under
|
|
490
|
+
// it. The temp `entries` it lists are equally collision-free (each carries
|
|
491
|
+
// its own random suffix from `stageWriteFileSync`).
|
|
304
492
|
writeFileSync(
|
|
305
493
|
journalPath,
|
|
306
494
|
JSON.stringify({
|
|
@@ -334,6 +522,24 @@ function commitGroupRename(indexDir, entries) {
|
|
|
334
522
|
// (their previous content is already overwritten — the rename was
|
|
335
523
|
// atomic at each individual leg, just not as a group). The next run
|
|
336
524
|
// is idempotent and will rewrite all three from scratch.
|
|
525
|
+
//
|
|
526
|
+
// ingest-A-001: the group is now reader-inconsistent (promoted finals
|
|
527
|
+
// carry new content; stale finals carry old content). Name which finals
|
|
528
|
+
// are which in a structured error event so an operator can force an
|
|
529
|
+
// immediate rebuild rather than wait for the next scheduled run to heal
|
|
530
|
+
// the torn group.
|
|
531
|
+
const promoted = stagedTmps.slice(0, promotedCount).map((e) => e.finalPath);
|
|
532
|
+
const stale = stagedTmps.slice(promotedCount).map((e) => e.finalPath);
|
|
533
|
+
logStage('error', {
|
|
534
|
+
kind: 'commit_group_partial_promote',
|
|
535
|
+
reason: err && err.code ? err.code : 'promote_failed',
|
|
536
|
+
promoted_count: promotedCount,
|
|
537
|
+
total: stagedTmps.length,
|
|
538
|
+
promoted_finals: promoted,
|
|
539
|
+
stale_finals: stale,
|
|
540
|
+
journal: journalPath,
|
|
541
|
+
error: err && err.message ? err.message : String(err),
|
|
542
|
+
});
|
|
337
543
|
throw new Error(
|
|
338
544
|
`commitGroupRename: promote failed after ${promotedCount}/${stagedTmps.length} files; ` +
|
|
339
545
|
`journal preserved at ${journalPath} for next-run cleanup. Original error: ${err.message}`
|
|
@@ -347,11 +553,42 @@ function commitGroupRename(indexDir, entries) {
|
|
|
347
553
|
try { unlinkSync(journalPath); } catch { /* will be cleaned next run */ }
|
|
348
554
|
}
|
|
349
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Probe whether a pid is still a live process. `process.kill(pid, 0)` sends
|
|
558
|
+
* no signal — it only performs the permission/existence check, throwing
|
|
559
|
+
* ESRCH when the pid is dead. An EPERM means the process exists but is owned
|
|
560
|
+
* by another user; that still counts as "live" for our purpose (do not reap
|
|
561
|
+
* its journal). Any other error (or a non-integer pid) is treated as "not
|
|
562
|
+
* provably live" so a malformed journal never blocks its own cleanup.
|
|
563
|
+
*
|
|
564
|
+
* @param {unknown} pid
|
|
565
|
+
* @returns {boolean}
|
|
566
|
+
*/
|
|
567
|
+
function isProcessAlive(pid) {
|
|
568
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
569
|
+
try {
|
|
570
|
+
process.kill(pid, 0);
|
|
571
|
+
return true;
|
|
572
|
+
} catch (err) {
|
|
573
|
+
return err && err.code === 'EPERM';
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
350
577
|
/**
|
|
351
578
|
* Find and clean up any in-progress journals from previous runs. Each journal
|
|
352
579
|
* lists the temp paths that were staged; we unlink any that still exist
|
|
353
580
|
* (they are residue from a crashed run) and delete the journal.
|
|
354
581
|
*
|
|
582
|
+
* ingest-A-002: cleanup is PID-AWARE. A journal whose `pid` is a still-live
|
|
583
|
+
* process is the in-flight recovery state of a concurrent rebuild — reaping
|
|
584
|
+
* it would delete that run's temps and journal mid-flight. Today the only
|
|
585
|
+
* writer serializes rebuilds and `rebuildIndexes` is synchronous, so no live
|
|
586
|
+
* sibling journal exists at Phase-0 cleanup time; this guard makes the design
|
|
587
|
+
* correct (not merely safe-by-serialization) so a future maintainer who adds
|
|
588
|
+
* concurrency does not silently corrupt a peer. A dead pid, a missing/
|
|
589
|
+
* malformed pid, or an unreadable journal is still reaped — that is the
|
|
590
|
+
* crashed-run residue this function exists to clear.
|
|
591
|
+
*
|
|
355
592
|
* Idempotent: on a clean filesystem it's a no-op; on a crashed-mid-promote
|
|
356
593
|
* filesystem it cleans the slate so the upcoming `commitGroupRename` can
|
|
357
594
|
* stage fresh temps without colliding.
|
|
@@ -372,6 +609,11 @@ function cleanupCrashedJournals(indexDir) {
|
|
|
372
609
|
// referenced will linger but they're harmless (they have a unique
|
|
373
610
|
// suffix that won't be re-used).
|
|
374
611
|
}
|
|
612
|
+
// Skip a journal owned by a still-live process — it belongs to a
|
|
613
|
+
// concurrent rebuild's in-flight recovery state, not crashed residue.
|
|
614
|
+
if (parsed && isProcessAlive(parsed.pid) && parsed.pid !== process.pid) {
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
375
617
|
if (parsed && Array.isArray(parsed.entries)) {
|
|
376
618
|
for (const e of parsed.entries) {
|
|
377
619
|
if (e && typeof e.tmpPath === 'string') {
|