@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.
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * Regenerated on every accepted/rejected write in Phase 1.
10
10
  *
11
- * Multi-file commit-group atomicity (W3-PIPE-002):
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
- * Pattern reference: choke-point fix (Pattern #4) for multi-file atomicity.
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 is
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
- const entries = readdirSync(dir, { withFileTypes: true });
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
- // Collect all records (accepted + rejected)
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
- const relPath = relative(repoRoot, f);
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
- * - Crash during STAGE phase: every staged temp is unlinked in the catch
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
- * - Crash during PROMOTE phase: any successfully-renamed file is at its
275
- * final path; remaining temps are still next to their finals. The
276
- * journal still exists. Next run's `cleanupCrashedJournals` deletes
277
- * residual temps and the journal; the next normal `rebuildIndexes`
278
- * call rewrites all 3 indexes from scratch (idempotent).
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 (the pid suffix guarantees no
303
- // collision with concurrent rebuilds).
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') {