Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-fs): confirm absence on disk before publishing an external delete - #12695
Conversation
…al delete A watcher unlink is a claim of absence, not absence. chokidar reaches its removal path from failed stats as well as from real removals, so under filesystem pressure it retires files that are still there; publishing those claims produced a durable delete/create pair for an item nobody removed. Confirm against the disk under the same per-key lock the reconciliation sweep already used for this, and fall through to the content path when the file is still present, so a spurious unlink alongside a real external edit surfaces as the update it always was. Part of #7369 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DKWDdUJ2XNRESVVWUvcpnh
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 19e2fcf1a5d22ad57e443a69543ce313e00ef437 && git checkout 19e2fcf1a5d22ad57e443a69543ce313e00ef437
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 284fc22d8834f2c9a3530592d6614e0eb6a28590 6ff0c6b0526210dcf3694a8c5256f9b3ae429af6 && git checkout -B drift-repro 284fc22d8834f2c9a3530592d6614e0eb6a28590 && git merge --no-ff 6ff0c6b0526210dcf3694a8c5256f9b3ae429af6
node scripts/docs-audit/affected-docs.mjs --json 284fc22d8834f2c9a3530592d6614e0eb6a28590
|
os-zhuang
left a comment
There was a problem hiding this comment.
PM verification record (dispatching seat for #7369; this seat shares the PR's author identity, so this is a COMMENT review — packages/metadata-fs is not a governed surface and the queue does not require an approval here).
Read the full diff. The dispatch asked the dev to decide a fork by reading code — watcher contract vs. test tolerance — and the evidence for the branch taken is decisive:
- This was never test flakiness; it was a product defect the queue's full-suite load surfaced twice. The removal face published a
deleteon chokidar's word alone, and chokidar reaches its removal path from failed stats (both cited sites in chokidar 5handler.js). The blast radius was durable: a phantomdeleteappended to the change log and broadcast, registry/list()eviction, then a sweep-republishedcreate— a permanent delete/create pair for a file nobody removed. Exactly the silent-corruption shape the repo's loud-failure discipline exists to catch. - The fix imports the discipline the reconciliation sweep already had (absence confirmed on disk under the same per-key lock) and falls through so a spurious unlink riding a real edit surfaces as the
updateit always was.watch-dot-root.test.tsuntouched, as the reopened card demanded. - The new pin asserts both directions at the handler seam — spurious-unlink-with-edit ⇒ update; pure spurious unlink ⇒ nothing, history stays clean; genuine removal ⇒ delete on first delivery — which forecloses both trivial wrong fixes (ignore-all-unlinks and status quo). No wall-clock races introduced.
- Reverse verification is the strongest in this batch: pre-fix code restored by blob hash, predicted failure set measured exactly, and the failing assertion byte-identical to queue run 33057527457's — the defect, the log, and the fix agree.
- Docs-drift's flagged page (
content/docs/concepts/metadata-lifecycle.mdx) re-read against this diff: it documents the event vocabulary and SSE bridge, makes no claim this change falsifies. No doc edit owed. - Finding #12696 (chokidar
atomic: truevia dead-code default — an unchosen behavior in the delivery path) correctly left out of this PR and filed for triage.
Landing via the normal queue. This also removes the standing cause of the two queue ejections recorded on #7369.
Generated by Claude Code
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7369
The fork this card set, and which side the code decided
The reopened card gave two branches and said the dev must decide by reading the watcher's event-normalization code, not by picking the cheaper edit:
delete, fix the test;metadata-fs, with the test left strict.It promises it, so this is the watcher fix.
watch-dot-root.test.tsis not touched.The exact code that decided it
handleFsChange's removal face published on the observer's word alone:The only suppression inside
publishExternalDeleteis!currentHead— a comparison against the index, which is exactly what a spurious unlink leaves intact. It answers "is this removal ours"; nothing answered "did a removal happen".The repository already held the opposite discipline one method away. The reconciliation sweep's delete pass re-checks disk truth under the same per-key lock before retiring a key, and says why in place:
and
test/external-write-resync.test.tspins that contract for the sweep — when a false absence is injected,expect(events.filter((e) => e.op === 'delete')).toHaveLength(0), with the comment "every item of that type would otherwise be retired as an external delete". The watcher face was the one path in the package that skipped the check.Why the queue log is that defect and not a slow runner
From the job log itself (run 33057527457,
Test Core (2/6)): the failure is atwatch-dot-root.test.ts:268, so the exact-count assertion on line 267 passed. Exactly one event arrived, and it was typeddelete, for a file the case creates withput()and then rewrites in place withfs.writeFileand never removes. The file ran in 2511ms, so this was not a late delivery — the phantom removal arrived promptly. Adeletepublished there means the repository retired an item that was on disk.Why chokidar's
unlinkis not evidence of removalchokidar reaches its removal path from failed stats as well as from real removals, and says so at both sites (chokidar 5
handler.js):_handleFile's poll listener runs whenfs.watchFilereports a zeroed stat, re-stats the file, and calls_removefrom thecatch— under the comment "Fix issues where mtime is null but file is still present" — with no discrimination on errno, soEMFILE/ENFILEretires a file that is there;_handleRead's snapshot diff_removes every previously tracked entry its readdirp pass did not enumerate, which includes entries whose per-entrylstatfailed rather than only the ones that are gone.Both faults are load-shaped, which is why this surfaces in the merge queue and nowhere else: the queue is the only context that runs the FULL suite, and PR-side CI runs the affected subset.
What the bug cost, beyond one red test
A
deleteis not a droppable notification. It is appended to the JSONL change log and broadcast to every subscriber, andMetadataManagerdrops the item from the registry and thelist()cache on receipt. The sweep then found the file still on disk and republished it as acreate. So a failed stat produced a durable delete/create pair for an item nobody removed, with a window in between where live metadata had disappeared — in the shipped.objectstack/metadatalayout, not only under test.The change
One guard in
handleFsChange, plus the doc block that now states the contract:Falling through is the repair, not just a skip: when the path is still there the honest reading of the event is "something happened to this file", which is the content path's question. It answers with the same
currentHead === hashcomparison used everywhere else, so a spurious unlink that arrived alongside a real in-place edit surfaces as theupdateit always was, in the same tick, instead of thedelete+createpair the index-only check produced. Genuine external removals are unaffected — for those the file really is gone, so they are still published on the first delivery — anddelete()'s own unlink is still suppressed by!currentHead.The pin
test/external-delete-requires-absence.test.ts, three cases, entered at the seam immediately below chokidar — the disciplineself-write-suppression.test.tsset in this package for this exact reason ("this package has been ejected from the merge queue twice already by wall-clock watcher assertions"). The upstream trigger is a failed stat under resource pressure, which cannot be summoned on demand and would be a wall-clock race to wait for; the boundary condition all of its causes share is "chokidar calls back withunlinkfor a path that is still there", and that is what the cases deliver. This mirrors howexternal-write-resync.test.tsreproduces its own family (detach the listeners) rather than forcing one named gate.Both directions are asserted, because each alone has a trivial wrong fix: a repository that published nothing on
unlinkwould pass the first two cases and fail the third.update, identity intact, item still readable;['create'];delete,hash: null,parentHash= the old head.Verification
Head of this branch:
6ff0c6b0. Every reading below is from that tree.Reverse verification —
packages/metadata-fs/src/repository.tsrestored fromorigin/main(the fix committed first, so the restore leg has a real reference point), mutation confirmed on disk by counting both anchors before measuring (fix guard 0 occurrences, pre-fix branch 1), restore proved by blob hash against the HEAD blob plus an emptygit diff HEAD. No rebuild is owed on either leg: the tests resolve the subject from source (../src/index.js, transformed by vitest), and the onlydistin play is@objectstack/metadata-core's, which the mutation does not touch.Predicted direction: cases 1 and 2 red, case 3 green. Measured, pre-fix:
The first line is byte-identical to the assertion that ejected PR #12684 from the merge queue.
Suite —
pnpm --filter @objectstack/metadata-fs test→Test Files 9 passed (9),Tests 68 passed (68)(CI on the failing run had 8 files / 65 tests; this adds one file and three cases).Typecheck —
pnpm --filter @objectstack/metadata-fs typecheck(tsc --noEmit && tsc --noEmit -p tsconfig.test.json) clean. Confirmed the new file is genuinely in the test program rather than silently excluded:tsc -p tsconfig.test.json --listFileslists it (1 occurrence, not 0).Repetition under load — 20 iterations of
watch-dot-root.test.ts+ the new pin, with 4 CPU hogs on a 4-core box (100% oversubscription): 20 passed / 0 failed,Tests 5 passed (5)in every iteration, zeroAssertionErrors.Gates — the family derived by
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the real change set (23 runs, exit codes captured before any pipe), all green. Their own verdict lines includecheck-nul-bytes: OK (scanned 7045 text file(s) ...; no raw ASCII control bytes),check-cross-package-test-inputs — OK: 20 package(s) read outside themselves, all declared,check-engine-double-contract: OK — 689 pinned, 134 in the DEBT ledger, 3 exempt,check-test-source-alias OK — 72 packages with tests scanned.What I did not establish
I did not observe a spurious chokidar
unlinkend-to-end on this container, and I am not claiming to have reproduced the queue's proximate trigger. I tried: a scratch probe drove case 1's exact shape against the real watcher with a tap counting every rawunlinkfor the item path, classifying each by whether the file still existed at that instant. 24 iterations with the process pushed to its file-descriptor ceiling, then 24 more with the same fd pressure plus 6 CPU hogs on 4 cores — both runs:spurious_unlink=0 real_unlink=0 delete_events=0 update_events=24. The probe was scratch and is not in this branch.So the mechanism above is read from chokidar's source and from what the job log proves must have happened (one event, typed
delete, for a file that existed) — not from a local reproduction. Which of the two chokidar branches fired on the runner is unknown, and per the reasoningexternal-write-resync.test.tsalready records for its own family, that is precisely the detail a pin should not depend on: the fix and its cases are keyed to the boundary condition, so they hold for either branch and for a third nobody has found.Scope
One package.
packages/specuntouched; #12684 remains open and is unrelated to this branch.content/docs/releases/untouched. A changeset is included because published runtime code changed.Generated by Claude Code