Repository navigation
fix(storage_engine): never reissue revisions across restarts so a stale expect_revision is refused - #390
Merged
Conversation
…tale expect_revision fails without expect_epoch (#380) Revisions restarted from 1 with the engine, so a write carrying a pre-crash expect_revision and no expect_epoch could match a revision of the new run and commit against a different history. The engine now keeps a durable bound on the revisions it has issued (metadata/revisions.json, raised a block of 2^20 at a time) and a starting engine continues above the bound its data directory holds. Every revision of an earlier run is then older than each knowledge graph's first snapshot in the new run and fails the precondition as predating it. A directory with state but no recorded bound (written by an earlier version) continues at 2^40. The wire protocol is unchanged: expect_epoch stays optional.
…sed mid-reservation
…se; cross-test race unresolved
… S12; regenerate docs bundle
…ck. The full `make unit-test` run on the rebased tree was started but had not finished when this result was requested, so the full suite is unverified. Changes are uncommitted in the worktree on top of the rebased commits. **ci-1 (Tests: `failed_restore_into_an_empty_mount_point_leaves_it_empty`)** — caused by this PR, a race rather than a broken test. - Invariant: once an engine has closed and released its data-directory lock, the process writes nothing more into that directory. - Cause: `reserve_through` upgrades a `Weak` to each open engine's reservation and rewrites `metadata/revisions.json`. Another test's write could still be mid-write to a just-dropped engine's directory, so `backup::create` listed the temp file and then found it gone. - Sites writing the bound: `open` (runs under the directory lock, already holds) and `reserve_through` (the violating one). - Fix: the engine now holds `revisions::OpenReservation`, whose `Drop` calls `Counter::close`. That takes the reservations lock, so it waits out a raise in progress, and removes the engine from the list. The field is declared before `_data_dir_lock`, so the close happens before the lock is released. - Files: `src/storage_engine/revisions.rs`, `src/storage_engine/mod.rs`. - Regression test: `a_closed_engine_directory_is_not_written_by_a_raise_in_progress` in `revisions_tests.rs`. It fails with `close` made a no-op (bound 2621440 written after close vs 1048576) and passes with the fix. **ci-2 (merge conflicts)** — two conflicts resolved. - `TESTING.md`: base removed the #364 expected failure and this branch removed #380, so the sentence now names neither. - `gui/lib/docs-bundle.ts`: regenerated with `node gui/scripts/bundle-docs.mjs`, no hand edits. **Verification run** - `cargo test --lib storage_engine::revisions`: 10 passed. - `cargo test --test backup_safety_tests`: 12 passed, 8 runs in a row. - `cargo clippy --all-targets`: finished clean. `cargo fmt` applied. **Conflict with a recorded decision, left as decided:** with engines now closing under the reservations lock, the operator-requested closed-engine branch in `reserve_through` (`strong_count() == 0`) can no longer fire from a real engine. Only the `Counter`-level tests reach it. I kept the branch and its tests unchanged
…closed-engine branch
jsam
force-pushed
the
fm/il-380-expect-rev
branch
from
October 6, 2026 00:07
0a8e167 to
1c9f103
Compare
This was referenced Oct 6, 2026
Closed
jsam
added a commit
that referenced
this pull request
Oct 6, 2026
… docs make doc-check failed on main since #390: rustdoc denies a public doc linking to a private item.
jsam
added a commit
that referenced
this pull request
Oct 6, 2026
…n-finite float literals (#301, #397) (#405) * test: cargo-fuzz targets for the IQL parser and /ws client frames (#301) Three targets run the server's own code through a test-support seam (src/fuzzing.rs) on an engine-sized stack at the nesting ceiling: iql_statement, iql_program (split, parse, bind params, authorize) and ws_client_frame (pre-auth decode, id probe, request classification). Beyond crashes they check that a persistent rule reloads unchanged and that a request run read-only holds only queries. fuzz/seeds.py seeds them from the example programs, every /ws frame type and the #295 deep-nesting and oversized-body cases; fuzz/run.sh (make fuzz) runs a campaign that keeps going past findings. * docs(storage_engine): unlink the private revisions module from public docs make doc-check failed on main since #390: rustdoc denies a public doc linking to a private item. * fix(parser): refuse float literals beyond f64's range (#397) A persistent rule holding 1e400 (or inf, NaN) was stored with a JSON null, which failed to decode on the next start: WalUnreadable, and the engine would not start. Every float literal now goes through float_literal: decimal syntax only, and a literal beyond f64's range is a validation error. Rust's inf/NaN spellings are identifiers again. Negating i64::MIN in a literal no longer overflows. Found by the iql_statement fuzz target; its inputs are kept in fuzz/regressions/.
This was referenced Oct 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Fix GitHub issue #380 in inputlayer/inputlayer (production readiness, correctness): "Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused".
Found by the scenario suite (S12, #336): after a crash/restart the engine starts a new stream epoch and revisions restart, so a write carrying a pre-crash expect_revision but no expect_epoch can match a new revision with the same number and be accepted silently. A precondition must never pass on a different history. Fix: make revisions monotonic across restarts (persist the counter), or refuse expect_revision without a matching epoch after a restart. Add the bare pre-crash case to S12 as a required assertion.
Deliver it as a small PR through the merge train; done means merged with CI green.
What Changed
storage_engine::revisionsmodule keeps a durable upper bound on issued revisions inrevisions.jsonat the data-directory root, raised a block (2^20) at a time; a starting engine continues above the bound it finds (or from a high floor of 2^40 when the directory has state but no recorded bound). A pre-crashexpect_revisiontherefore predates the knowledge graph in the new run and fails withprecondition_failed, with or withoutexpect_epoch.tests/scenarios/restart.rs) now requires the bare pre-crashexpect_revisionto be refused (the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380KnownDefectmarker is removed) and asserts that the first post-restart revision exceeds the last pre-crash one. Unit tests are added for the revision counter and bound; the WebSocket epoch-mismatch error text, protocol doc comments, AsyncAPI spec, guides,TESTING.mdand the regenerated docs bundle are updated to match.Risk Assessment
✅ Low: Both round 6 decisions are applied as instructed (bound moved to
revisions.jsonunder the data directory root, where no graph name can collide and offline backup still copies it; dead closed-engine branch and its test removed), and tracing open, raise, close, write-failure and restart sequences found no path that issues a revision above the durable bound; the only remaining gap is the restore-from-backup limit the operator already deferred.Testing
Ran the S12 restart scenario, which spawns and crash-restarts the real server binary, and it passed as a required assertion. Then drove a hand-written WebSocket client against the real server on a throwaway data directory through five runs separated by SIGKILL: 20 of 20 checks passed, covering bare, epoch-pinned and unscoped stale preconditions, a sweep of every pre-crash revision number, a current precondition still committing, a dropped graph named
metadata, and a deletedrevisions.json. No UI surface is involved, so the evidence is protocol transcripts. The temp data directories were removed and the worktree is clean.restart::s12_restart_mid_scenario_preserves_revisions ... okrevision 8 predates this knowledge graph's state in this engine run (from revision 1048579) ... Nothing was applied.;?claimreturns only the two run-1 rowsprecondition_failedKnowledge graph 'metadata' dropped.,revisions.json reserved=3145728still present, run 4 first write at 3145731revision 3145733 predates ... (from revision 1099511627780)Evidence: Live crash/restart WebSocket transcript (20/20 checks)
Evidence: Driver script used for the live transcript
Evidence: S12 scenario run
Evidence: Server replies to stale preconditions after SIGKILL and restart
Perf gate and known limits
make perf-gate-remote REV=1c9f1035 PERF_GATE_ARGS="--rounds 30", bench host, run20261006T001854Z-1c9f1035): PASS. Measured on this PR's head1c9f1035b3af9f61af885e9d1778e414fc3eb3b9(after all review fixes) against baselinee732cd52, 30 rounds per arm, host load ~2. Every required metric passed: 46 PASS, 0 FAIL, 0 INCONCLUSIVE; the 8 INVALID series are non-required p99/subscribe metrics with too few baseline samples. The two metrics that were inconclusive in the earlier 10-round run ond7490e47now pass:bound_query.queries_per_seccost ratio 0.85 (interval 0.84..0.86) andcheap_query.queries_per_sec1.03 (interval 1.02..1.04, budget 1.05); the closest to its budget ischeap_query.queries_per_sec. Commit-path metrics:insert_single.ack_us.p500.20,insert_batch.ack_us.p500.40,interference.delta_us.p990.74 (interval 0.68..0.87). The large improvements against the baseline come from main sincee732cd52, not from this PR. Reports:target/perf-gate/remote/20261006T001854Z-1c9f1035on the runner's copy.expect_revisioncan pass; Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 covers crash/restart only and this goes up as a follow-up.Pipeline
Updates from git push no-mistakes
... (3 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)
🔧 **Review** - 3 issues found → auto-fixed (5) ✅
🔧 Fix applied.
5 issues (4 warnings, 1 info) still open:
src/storage_engine/revisions.rs:101- A restore from backup can still reissue revisions, so a bare staleexpect_revisioncan pass on a different history. The bound is read only from the data directory, and a restored directory carries an older bound or none. (1) Offline backup copiesmetadata/revisions.jsonas it was at backup time (src/storage/backup/tree.rs:27skips only LOCK and the manifest). Sequence: back up at bound 2^20; the engine restarts and issues 2^20+1 onwards; a client observes 2^20+5; restore the backup; the restored engine continues at 2^20 and reissues 2^20+5 for a rolled-back history. (2) The online export (src/storage/backup/export.rs:141-143) writespersist/but norevisions.json, so every restored export starts atUNRECORDED_FLOOR(2^40). Two restores of an export, or a restore of a directory that was itself upgraded from an earlier version, collide from 2^40+1. This path was equally open before the change and the issue names crash/restart only, so it is not a regression. The remedy needs authorization rather than the defect: closing it means extending the change to the backup/restore path (for example, excluding or raising the bound on restore, or requiringexpect_epochafter one).src/storage_engine/revisions.rs:88- When the durable write of the bound fails, the revision is issued anyway.reserve_throughlogsrevision_reservation_failedandnext()still returns a revision above the bound on disk. Sequence:save_json_atomicfails (ENOSPC, EIO, or a permission error onmetadata/) when the counter crosses a block; commits that still succeed return revisions above the recorded bound to clients; the engine crashes; the restarted engine continues at the old bound and reissues those revisions. That is the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 failure again, limited to the window in which the write is failing. Each revision in that window also retries the temp-file write and fsync under the global mutex. The remedy needs authorization because it changes behavior:next_revision()is infallible and is called from snapshot construction, so failing closed means refusing the commit or aborting the process.src/storage_engine/revisions.rs:82- Unrequired component: the branch inreserve_throughthat drops a reservation when itsmetadata/directory no longer exists (commented "A removed data directory (a test's)"). No intent requirement needs it. It exists so that leaked test engines do not recreate deleted temp directories. In production it fails open: an open engine whosemetadata/is missing at that instant is removed from the list for good,Counter.reservedreturns tou64::MAX, and its bound is never raised again without any log line. Recommend removing the branch. Dead engines are already pruned through theWeakupgrade.src/storage_engine/residency.rs:406- Unrequired component, added by fix round 1: thereserve_ahead()?gate inactivate().activateis reached throughkg_handle, which every read uses, so this gate refuses reads as well as writes. Sequence: the disk fills (ormetadata/becomes unwritable) while the counter is withinHEADROOMof the bound; a client queries a dormant knowledge graph;kg_handle->activate->reserve_aheadreturns the write error and the query fails. Before this round that read succeeded. The operator's decision asked only that "the commit that needs the new block must fail"; the gates inlock_kg(residency.rs:339) andcreate_graph(mod.rs:405) already cover every commit. Without the gate an activation still cannot issue a revision above the bound, becausenext_revision()panics rather than return one. Recommend removing the gate inactivate. This needs a decision because it trades read availability during a storage fault against an activation failing by panic in the far corner where 2^19 revisions are used with no write in between.src/storage_engine/revisions.rs:86- One revision-consuming path does not passreserve_ahead, so the claim onnext_revisionthat "Writes are refused long before that" (revisions.rs:58-59) does not hold for it. Fix round 1 gatedlock_kg,activateandcreate_graphand left this sibling behind.WriteProgram::view(src/storage_engine/write_program.rs:254) builds a new snapshot, and so takes a revision, for each fact statement that reads state after a rule change. It is called while staging (src/protocol/handler/write_run.rs:290), before the commit reacheslock_kg;staging_base(src/storage_engine/mod.rs:834) useskg_handleonly. Sequence: the bound cannot be written and the counter is in the headroom zone; clients retry programs that change a rule and then read; each retry consumes revisions and its commit is refused; after 2^19 such revisions the next staging view panics in the handler task instead of returning an error. No revision above the bound is returned, so the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 invariant holds; the cost is a panic where an error was intended. Smallest fix: callrevisions::reserve_ahead()?instaging_base, which only write programs use.🔧 Fix applied.
5 issues (4 warnings, 1 info) still open:
src/storage_engine/revisions.rs:101- A restore from backup can still reissue revisions, so a bare staleexpect_revisioncan pass on a different history. The bound is read only from the data directory, and a restored directory carries an older bound or none. (1) Offline backup copiesmetadata/revisions.jsonas it was at backup time (src/storage/backup/tree.rs:27skips only LOCK and the manifest). Sequence: back up at bound 2^20; the engine restarts and issues 2^20+1 onwards; a client observes 2^20+5; restore the backup; the restored engine continues at 2^20 and reissues 2^20+5 for a rolled-back history. (2) The online export (src/storage/backup/export.rs:141-143) writespersist/but norevisions.json, so every restored export starts atUNRECORDED_FLOOR(2^40). Two restores of an export, or a restore of a directory that was itself upgraded from an earlier version, collide from 2^40+1. This path was equally open before the change and the issue names crash/restart only, so it is not a regression. The remedy needs authorization rather than the defect: closing it means extending the change to the backup/restore path (for example, excluding or raising the bound on restore, or requiringexpect_epochafter one).src/storage_engine/revisions.rs:88- When the durable write of the bound fails, the revision is issued anyway.reserve_throughlogsrevision_reservation_failedandnext()still returns a revision above the bound on disk. Sequence:save_json_atomicfails (ENOSPC, EIO, or a permission error onmetadata/) when the counter crosses a block; commits that still succeed return revisions above the recorded bound to clients; the engine crashes; the restarted engine continues at the old bound and reissues those revisions. That is the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 failure again, limited to the window in which the write is failing. Each revision in that window also retries the temp-file write and fsync under the global mutex. The remedy needs authorization because it changes behavior:next_revision()is infallible and is called from snapshot construction, so failing closed means refusing the commit or aborting the process.src/storage_engine/revisions.rs:82- Unrequired component: the branch inreserve_throughthat drops a reservation when itsmetadata/directory no longer exists (commented "A removed data directory (a test's)"). No intent requirement needs it. It exists so that leaked test engines do not recreate deleted temp directories. In production it fails open: an open engine whosemetadata/is missing at that instant is removed from the list for good,Counter.reservedreturns tou64::MAX, and its bound is never raised again without any log line. Recommend removing the branch. Dead engines are already pruned through theWeakupgrade.src/storage_engine/revisions.rs:86- One revision-consuming path does not passreserve_ahead, so the claim onnext_revisionthat "Writes are refused long before that" (revisions.rs:58-59) does not hold for it. Fix round 1 gatedlock_kg,activateandcreate_graphand left this sibling behind.WriteProgram::view(src/storage_engine/write_program.rs:254) builds a new snapshot, and so takes a revision, for each fact statement that reads state after a rule change. It is called while staging (src/protocol/handler/write_run.rs:290), before the commit reacheslock_kg;staging_base(src/storage_engine/mod.rs:834) useskg_handleonly. Sequence: the bound cannot be written and the counter is in the headroom zone; clients retry programs that change a rule and then read; each retry consumes revisions and its commit is refused; after 2^19 such revisions the next staging view panics in the handler task instead of returning an error. No revision above the bound is returned, so the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 invariant holds; the cost is a panic where an error was intended. Smallest fix: callrevisions::reserve_ahead()?instaging_base, which only write programs use.src/storage_engine/revisions.rs:120- Unrequired component, added by fix round 2: the branch inreserve_throughthat drops a reservation and swallows its write error when the engine closed during the write (if open.strong_count() == 0 { return false; }, with the restructuring around it at lines 113-127). No intent requirement needs it, and the operator's round 2 decision asked only for removing theactivategate and adding thestaging_basegate ("Nothing more"). A production process has one engine, so the branch can only fire at shutdown, where the alternative is one in-flight commit returning an error. Its practical use is parallel tests sharing the static counter, where one test's engine and temp directory go away while another test's commit raises bounds. That is the same role as the test-only prune branch removed in round 1. I found no wrong result from it: an engine with no strong reference issues no more revisions, so ignoring its failed write cannot let a revision pass a bound. No test covers it. Recommend revertingreserve_throughto its round 1 form. This needs a decision because removal may bring back a cross-test race that the fixer presumably added it for.🔧 Fix applied.
9 issues (1 error, 6 warnings, 2 infos) still open:
src/storage_engine/revisions.rs:101- A restore from backup can still reissue revisions, so a bare staleexpect_revisioncan pass on a different history. The bound is read only from the data directory, and a restored directory carries an older bound or none. (1) Offline backup copiesmetadata/revisions.jsonas it was at backup time (src/storage/backup/tree.rs:27skips only LOCK and the manifest). Sequence: back up at bound 2^20; the engine restarts and issues 2^20+1 onwards; a client observes 2^20+5; restore the backup; the restored engine continues at 2^20 and reissues 2^20+5 for a rolled-back history. (2) The online export (src/storage/backup/export.rs:141-143) writespersist/but norevisions.json, so every restored export starts atUNRECORDED_FLOOR(2^40). Two restores of an export, or a restore of a directory that was itself upgraded from an earlier version, collide from 2^40+1. This path was equally open before the change and the issue names crash/restart only, so it is not a regression. The remedy needs authorization rather than the defect: closing it means extending the change to the backup/restore path (for example, excluding or raising the bound on restore, or requiringexpect_epochafter one).src/storage_engine/revisions.rs:88- When the durable write of the bound fails, the revision is issued anyway.reserve_throughlogsrevision_reservation_failedandnext()still returns a revision above the bound on disk. Sequence:save_json_atomicfails (ENOSPC, EIO, or a permission error onmetadata/) when the counter crosses a block; commits that still succeed return revisions above the recorded bound to clients; the engine crashes; the restarted engine continues at the old bound and reissues those revisions. That is the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 failure again, limited to the window in which the write is failing. Each revision in that window also retries the temp-file write and fsync under the global mutex. The remedy needs authorization because it changes behavior:next_revision()is infallible and is called from snapshot construction, so failing closed means refusing the commit or aborting the process.src/storage_engine/revisions.rs:82- Unrequired component: the branch inreserve_throughthat drops a reservation when itsmetadata/directory no longer exists (commented "A removed data directory (a test's)"). No intent requirement needs it. It exists so that leaked test engines do not recreate deleted temp directories. In production it fails open: an open engine whosemetadata/is missing at that instant is removed from the list for good,Counter.reservedreturns tou64::MAX, and its bound is never raised again without any log line. Recommend removing the branch. Dead engines are already pruned through theWeakupgrade.src/storage_engine/revisions.rs:86- One revision-consuming path does not passreserve_ahead, so the claim onnext_revisionthat "Writes are refused long before that" (revisions.rs:58-59) does not hold for it. Fix round 1 gatedlock_kg,activateandcreate_graphand left this sibling behind.WriteProgram::view(src/storage_engine/write_program.rs:254) builds a new snapshot, and so takes a revision, for each fact statement that reads state after a rule change. It is called while staging (src/protocol/handler/write_run.rs:290), before the commit reacheslock_kg;staging_base(src/storage_engine/mod.rs:834) useskg_handleonly. Sequence: the bound cannot be written and the counter is in the headroom zone; clients retry programs that change a rule and then read; each retry consumes revisions and its commit is refused; after 2^19 such revisions the next staging view panics in the handler task instead of returning an error. No revision above the bound is returned, so the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 invariant holds; the cost is a panic where an error was intended. Smallest fix: callrevisions::reserve_ahead()?instaging_base, which only write programs use.src/storage_engine/revisions.rs:120- Unrequired component, added by fix round 2: the branch inreserve_throughthat drops a reservation and swallows its write error when the engine closed during the write (if open.strong_count() == 0 { return false; }, with the restructuring around it at lines 113-127). No intent requirement needs it, and the operator's round 2 decision asked only for removing theactivategate and adding thestaging_basegate ("Nothing more"). A production process has one engine, so the branch can only fire at shutdown, where the alternative is one in-flight commit returning an error. Its practical use is parallel tests sharing the static counter, where one test's engine and temp directory go away while another test's commit raises bounds. That is the same role as the test-only prune branch removed in round 1. I found no wrong result from it: an engine with no strong reference issues no more revisions, so ignoring its failed write cannot let a revision pass a bound. No test covers it. Recommend revertingreserve_throughto its round 1 form. This needs a decision because removal may bring back a cross-test race that the fixer presumably added it for.tests/scenarios/restart.rs:34- The rebase brought S12 (tests/scenarios/restart.rs) onto this branch. It still wraps the bare pre-crash expect_revision case in the KnownDefect STALE_REVISION_ACCEPTED (Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380, judged at line 190), and its module docs say revisions restart with the engine and that the bare claim is accepted today. This change fixes Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380, so that expected failure now passes, and the scenario suite fails a run on an unexpected pass. The intent requires the bare pre-crash case in S12 as a required assertion. Fix: remove the KnownDefect and assert the refusal directly as a required pass in S12; correct the S12 module docs and any TESTING.md known-defect listing for Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 to match (a restarted engine continues above every revision of earlier runs, so the bare pre-crash revision is refused as predating the knowledge graph in this run); check that the S12 step that advances the new run until it has issued the pre-crash revision still terminates and still means something now that the new run starts above it, and simplify it if not. S12 then covers what tests/scenarios/stream.rs::a_bare_expect_revision_from_before_a_restart_is_refused was added for before S12 existed on main: remove that duplicate scenario and its mentions in the stream.rs module docs and TESTING.md.tests/scenarios/restart.rs:176- Fix round 3 introduced this: the required S12 bare assertion no longer distinguishes the fix from the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 bug. The round replaced thewrites.iter().cycle()loop, which advanced the new run until it had issuedclaimed_at, with exactly two writes (line 104). The assertionrefused(reply, "precondition_failed", "revision")matches both refusal messages in src/storage_engine/precondition.rs:65-75:Unissued("revision N has not been issued") andPredates("revision N predates"). Sequence with the fix reverted: the restarted run counts from 1 and after two writes is still belowclaimed_at(the old loop had to cycle to reach it), so the bare claim is refused asUnissuedand S12 passes with the bug present. The intent requires the bare pre-crash case as a required assertion, and the test-quality rule requires a regression test that fails before the fix. Smallest fix, test only: assert the property the fix provides, for examplesnapshot > claimed_atafter the resubscribe at line 70 (the strategy's original ask that the first post-restart revision exceeds the last pre-crash one), and match "predates" instead of "revision" at line 176.src/storage_engine/revisions.rs:109- Fix round 3 reverted the closed-engine branch as decided but left the race it covered open; its commit title says "cross-test race unresolved". The operator's decision placed that fix in the tests. Sequence in the lib test binary, where all engines share the staticREVISIONS: test A reopens a data directory, soopen(line 144) raiseslastto that directory's recorded bound, which is above every other open engine'slast + HEADROOM. The next write in any test then runsreserve_throughand rewritesmetadata/revisions.jsonfor every live engine. If test B drops its engine andTempDirbetween theupgrade()at line 110 and the write at line 115,save_json_atomiceither recreates B's removed directory throughcreate_dir_all(a leaked temp directory) or fails against the concurrent removal. In the failing case the unrelated test's write returns an error fromreserve_ahead, or panics innext()at line 86. The result is an intermittent failure in tests that have no defect. Production has one engine and is not affected. The remedy needs a decision on scope, not on the defect: serialising or isolating every engine test that shares the counter is a broad test change, and the fixer did not attempt it.gui/lib/docs-bundle.ts:3920- The tracked, generated docs bundle still carries the old websocket-api text "Revisions restart with the engine, so a revision from another run fails the precondition". The change rewrote that sentence in docs/content/docs/guides/websocket-api.mdx:198, and earlier commits that touched that page (for example 9aa2275) regenerated the bundle in the same commit. Regenerate it withnode gui/scripts/bundle-docs.mjsso the in-app docs match the new contract.🔧 Fix applied.
2 issues (1 warning, 1 info) still open:
src/storage_engine/revisions.rs:101- A restore from backup can still reissue revisions, so a bare staleexpect_revisioncan pass on a different history. The bound is read only from the data directory, and a restored directory carries an older bound or none. (1) Offline backup copiesmetadata/revisions.jsonas it was at backup time (src/storage/backup/tree.rs:27skips only LOCK and the manifest). Sequence: back up at bound 2^20; the engine restarts and issues 2^20+1 onwards; a client observes 2^20+5; restore the backup; the restored engine continues at 2^20 and reissues 2^20+5 for a rolled-back history. (2) The online export (src/storage/backup/export.rs:141-143) writespersist/but norevisions.json, so every restored export starts atUNRECORDED_FLOOR(2^40). Two restores of an export, or a restore of a directory that was itself upgraded from an earlier version, collide from 2^40+1. This path was equally open before the change and the issue names crash/restart only, so it is not a regression. The remedy needs authorization rather than the defect: closing it means extending the change to the backup/restore path (for example, excluding or raising the bound on restore, or requiringexpect_epochafter one).ℹ️
src/storage_engine/revisions_tests.rs:131- Fix round 4 addedan_engine_closing_while_bounds_are_raised_fails_no_writefor the restored closed-engine branch (revisions.rs:118-126). The branch only covers a bound write that fails. If the spawned thread drops the engine and itsTempDirafterupgrade()but beforesave_json_atomicreachescreate_dir_all(src/storage/metadata.rs:66), the write succeeds and recreates the removed directory, leaving a stray temp directory. The test passes either way, because it does not assert on the directory. The operator's "no directory is recreated" is asserted only in the deterministic test at line 110, which exercises the existingWeakprune and not the new branch. No wrong revision results: the engine has closed, and production has one engine. Recorded as a known limit of the branch; no change recommended.src/storage_engine/revisions.rs:23- The durable bound lives atmetadata/revisions.json, a path a knowledge graph drop can delete, after which a restart can reissue revisions (the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 failure). Traced from source, not executed.validate_kg_name(src/naming.rs:17) acceptsmetadataas a knowledge graph name and reserves no names. A knowledge graph's directory isdata_dir.join(name)(src/storage_engine/mod.rs:446), and dropping it runscleanup_dropped_kg->delete_kg_files, which callsremove_dir_all(data_dir/<name>)(mod.rs:575, mod.rs:166). Sequence: (1) a data directory upgraded from an earlier version opens with no record, so it continues atUNRECORDED_FLOOR(2^40) and issues 2^40+1 onwards; a client observes 2^40+5. (2) A knowledge graph namedmetadatais created and dropped, which removesmetadata/includingrevisions.json. The running engine rewrites the file only at the next block raise, up to 2^20 revisions later. (3) The engine restarts before that raise.openfinds no record andpersist/present, so it starts at 2^40 again (revisions.rs:152) and reissues 2^40+5 on a different history. A bareexpect_revisionat 2^40+5 can then pass. A directory that was never upgraded needs the drop-and-restart to happen twice. The name collision withmetadata/existed before this change (the drop also removes the knowledge graph list and tombstones, which are re-saved); this change adds a file there that is not re-saved and whose loss fails open. The remedy needs a decision, not the defect: either reservemetadataandpersistas knowledge graph names (a behavior change outside Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380), or keep the bound at a path no knowledge graph name can collide with, for examplerevisions.jsondirectly under the data directory (names allow only[a-z0-9_], so a dotted file name cannot collide), which also touches docs/content/docs/guides/backup.mdx:30.src/storage_engine/revisions.rs:124- Component no longer needed by any real engine: the closed-engine branch inreserve_through(if open.strong_count() == 0 { return false; }, lines 120-126). This is a new situation, not a repeat ofclosed-mid-reservation-branch-unrequested: the operator requested the branch in round 4 to cover the cross-test race, and the later CI fix (419473c) closed that race a second way.OpenReservation::dropnow callsCounter::close, which takes the reservations lock, and theArcis released only afterclosereturns.reserve_throughholds that lock for the whole raise, so a real engine's strong count cannot reach 0 while its bound is being written. Only theCounter-level tests that drop a bareArcreach the branch (an_engine_closing_while_bounds_are_raised_fails_no_write, revisions_tests.rs:131). The fixer's commit message records the same conflict and left the branch as decided. I found no wrong result from keeping it. Recommend removing the branch and that one test, and keepingcloseand its test (revisions_tests.rs:160) as the single mechanism. This needs a decision because it reverses a recorded operator instruction.🔧 Fix applied.
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
cargo test --test scenarios restart::, 1 passed)expect_revisionfrom before the crash getsprecondition_failedand nothing is applied+claim("o-1","i4","a1")withexpect_revision: 7commits at revision 10 and the row appearsexpect_revision1048580 (bare) and 1048582 (with epoch) both commitother, revision 4194310 refused after kill -9expect_revision: 6refused as predating; the old facts are intactmetadata/read-only, writes get an error (codeinternal, 'Permission denied'); a query still answers and /health is ok; after chmod back, revis…cargo test --test scenarios restart:: -- --nocapture(S12 against a real engine process; 1 passed)node drive.mjs target/debug/inputlayer-server <tmp> "target 8c82c38b": five restarts (kill -9 and SIGTERM), bare and epoch-pinned staleexpect_revision, in-run happy paths, second knowledge graph, missing and corruptmetadata/revisions.json(18 of 18 passed)SKIP_FIX_ONLY=1 node drive.mjs <base build>/inputlayer-server <tmp> "base 306df026": same driver on the base commit (6 of 15 failed; the bare stale write was accepted and applied)node drive-upgrade.mjs <base binary> <target binary> <tmp>: data directory written by the pre-fix engine, opened by the fixed engine (4 of 4 passed)node drive-bound.mjs target/debug/inputlayer-server <tmp>: pumped to revision 524268,chmod 555 metadata/, observed refusal, restored permissions (7 of 7 passed)Base binary built fromgit archive 306df026in a temp directory with its ownCARGO_TARGET_DIR, removed afterwards✅ No issues found.
restart::s12_restart_mid_scenario_preserves_revisions ... okrevision 8 predates this knowledge graph's state in this engine run (from revision 1048579) ... Nothing was applied.;?claimreturns only the two run-1 rowsprecondition_failedKnowledge graph 'metadata' dropped.,revisions.json reserved=3145728still present, run 4 first write at 3145731revision 3145733 predates ... (from revision 1099511627780)cargo test --test scenarios s12_restart -- --nocapture(S12 against the real server binary, 1 passed)node drive.mjs target/debug/inputlayer-server <tmpdir>: real server overws://127.0.0.1:<port>/ws, five runs separated by SIGKILL, 20/20 checks passedManual check in the driver:revisions.jsonsits directly under the data directory and itsreservedvalue is at or above every issued revision (1048576, then 2097152, then 3145728)Manual check in the driver:?claim(O, I)after the refused writes returns only the committed rows✅ **Document** - passed
✅ No issues found.
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ No issues found.
✅ **Push** - passed
✅ No issues found.
✅ No issues found.