Skip to content

fix(storage): stop acknowledging fact writes after the WAL file is removed or moved - #274

Merged
jsam merged 3 commits into
mainfrom
fm/en-en1
Oct 4, 2026
Merged

jsam merged 3 commits into
mainfrom
fm/en-en1

Conversation

@jsam

@jsam jsam commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Intent

please continue developing the input layer engine so we make sure that the symbolic engine is ready to go live and completely production ready while we develop the GoTM uh, market.

(Context: the captain approved the neurosymbolic decision engine plan; its milestone 'Engine readiness' makes the symbolic engine production-ready for the hosted decision API. This task is item EN-1: #93: fact inserts report success when the WAL write fails (data directory gone) — why: a SaaS cannot acknowledge writes it did not store; P0 data safety)

What Changed

  • PersistWal now records the device and inode of wal/current.wal (on non-Unix platforms only a missing file is detected). It checks that the file at that path is still the same one after every append, and before it reopens the writer, reads the file, or rewrites it. If the file was removed, moved or replaced, the commit returns OutcomeUnknown instead of success. The WAL then refuses every later write with StoreReadOnly until restart, and it does not quietly create a new, empty WAL. Truncation and rotation update the recorded file identity.
  • Unit tests in wal_tests.rs and a new integration test, tests/data_dir_loss_tests.rs, cover two cases. First, inserts made after the data directory is removed return outcome_unknown and then store_read_only, and those inserts do not show up in the live store. Second, a write into a data directory that was moved away is recovered once the directory is put back.
  • The persistence guide (docs/guides/persistence.md and the docs-site .mdx copy) now explains what happens if the WAL file or data directory is removed or moved while the server is running.

Risk Assessment

✅ Low: The fix round makes file_id an Option that persists while the writer is closed. It is set at open from the recovered file, refreshed on reopen and after retain_ops' rename, and cleared only in clear(). check_in_place now runs before ensure_writer reopens the file and in settle(), so read_all, retain_ops, remove_shard_entries, clear and Drop all refuse a removed or replaced WAL with OutcomeUnknown/read-only instead of creating a fresh file. The open-writer append path still does only one stat. The new tests drive the public WAL API and check observable refusals and file state.

Testing

I built the server and client from this change and drove five live scenarios on a disposable data directory. They covered removing the data dir, moving it away and restoring it across a restart, deleting current.wal while the writer was closed after a flush (forced with BUFFER_SIZE=2), and replacing the WAL with a different file with the writer open and closed. A happy path with many WAL self-rewrites followed by a restart also passed. In every failure case the insert returned outcome unknown rather than success, and later writes were refused as read-only. No empty WAL was created, and the refused rows never appeared in queries. A moved directory that was put back recovered the uncertain write, and writes resumed after restart. The change's targeted integration and WAL unit tests also pass. During setup I briefly thought SIGTERM shutdown hung, but my harness had signalled the wrong pid; with correct process tracking every server exited in about 2s. I removed all temp dirs and servers, and the worktree is unchanged.

  • Live validation: ✅ go - 5 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Data directory removed while server runs: next fact insert returns 'outcome unknown' (not success), later writes and rule changes are refused read-only, refused row not visible ✅ pass live scenario1-data-dir-removed.txt
Data directory moved away during a write, then put back and server restarted: the uncertain write (c2) is recovered and new writes are acknowledged ✅ pass live scenario2-data-dir-moved-and-restored.txt
Adversarial (review fix): WAL file deleted while writer closed after a buffer-full flush; next insert is refused (outcome unknown) instead of acknowledged into a fresh empty WAL ✅ pass live scenario3-wal-deleted-while-writer-closed.txt
Adversarial: WAL file replaced by a different file (same bytes, new inode) with writer open, and again with writer closed; insert refused as outcome unknown, then read-only ✅ pass live scenario4-wal-replaced-and-happy-path.txt (sections B, C)
No false positive: the server's own WAL rewrites (many flushes with BUFFER_SIZE=2) keep acknowledging inserts, and all rows survive a restart ✅ pass live scenario4-wal-replaced-and-happy-path.txt (section A)
Evidence: Data dir removed while running: insert refused (outcome unknown), then read-only
$ rm -rf $DATA_DIR   (server still running)
a.iql
pid
server.log
$ inputlayer-client b.iql  # +claim[("c2", 2)]

> +claim[("c2", 2)]
write outcome unknown, store read-only until restart recovery: write the WAL file /tmp/tmp.CjxjLyebSg/data/persist/wal/current.wal was removed or moved while the server ran (the path now cannot be read (No such file or directory (os error 2))); undo records written to the detached file are read by restart recovery only if it is put back
$ inputlayer-client c.iql
Authenticated as: admin
Current knowledge graph: default

> +claim[("c3", 3)]
store read-only until restart recovery
> +big(C) <- claim(C, V), V > 1
store read-only until restart recovery
> ?claim(C, V)
┌──────┬───┐
│ C    │ V │
├──────┼───┤
│ "c1" │ 1 │
└──────┴───┘
1 rows
--- server log ---
^[[2m2026-10-04T18:47:11.170947Z^[[0m ^[[31mERROR^[[0m ^[[2minputlayer::storage::persist::wal^[[0m^[[2m:^[[0m WAL file removed or moved while the server ran; refusing writes until restart ^[[3mfile^[[0m^[[2m=^[[0m/tmp/tmp.CjxjLyebSg/data/persist/wal/current.wal ^[[3mnow^[[0m^[[2m=^[[0mcannot be read (No such file or directory (os error 2))
Evidence: Data dir moved then restored: uncertain write recovered after restart, writes resume
$ insert c1
Inserted 1 fact(s) into 'claim'.
$ mv data moved   (server running)
$ insert c2
write outcome unknown, store read-only until restart recovery: write the WAL file /tmp/tmp.CjxjLyebSg/data/persist/wal/current.wal was removed or moved while the server ran (the path now cannot be read (No such file or directory (os error 2))); undo records written to the detached file are read by restart recovery only if it is put back
$ stop server (SIGTERM)
(server exited after ~2s)
$ mv moved data; restart server
Authenticated as: admin
Current knowledge graph: default

> ?claim(C, V)
┌──────┬───┐
│ C    │ V │
├──────┼───┤
│ "c1" │ 1 │
│ "c2" │ 2 │
└──────┴───┘
2 rows
> +claim[("c3", 3)]
Inserted 1 fact(s) into 'claim'.
> ?claim(C, V)
┌──────┬───┐
│ C    │ V │
├──────┼───┤
│ "c1" │ 1 │
│ "c2" │ 2 │
│ "c3" │ 3 │
└──────┴───┘
3 rows
(server exited after ~2s)
Evidence: WAL file deleted while writer closed after flush: refused, no fresh WAL created
# server started with INPUTLAYER_STORAGE__PERSIST__BUFFER_SIZE=2
$ +other[("b1")]
Inserted 1 fact(s) into 'other'.
# WAL: inode=7379498 size=856
$ +claim[("c1", 1), ("c2", 2)]
Inserted 2 fact(s) into 'claim'.
# claim buffer full -> flushed; WAL rewritten, writer closed: inode=7379415 size=856
$ rm data/persist/wal/current.wal   (directory left intact)
$ +other[("b2")]
write outcome unknown, store read-only until restart recovery: write the WAL file /tmp/tmp.CjxjLyebSg/data/persist/wal/current.wal was removed or moved while the server ran (the path now cannot be read (No such file or directory (os error 2))); undo records written to the detached file are read by restart recovery only if it is put back
# WAL path after refused write: ls: cannot access '/tmp/tmp.CjxjLyebSg/data/persist/wal/current.wal': No such file or directory
$ +claim[("c3", 3)]
store read-only until restart recovery
$ ?other(X)
┌──────┐
│ X    │
├──────┤
│ "b1" │
└──────┘
1 rows
(server exited after ~2s)
2026-10-04T18:49:33.424707Z ERROR inputlayer::storage::persist::wal: WAL file removed or moved while the server ran; refusing writes until restart file=/tmp/tmp.CjxjLyebSg/data/persist/wal/current.wal now=cannot be read (No such file or directory (os error 2))
Evidence: Happy path across WAL self-rewrites + WAL replaced (writer open / closed)
=== A: happy path, many WAL self-rewrites (BUFFER_SIZE=2), then restart ===
$ +claim[("c1", 1)]
Inserted 1 fact(s) into 'claim'.
$ +other[("b1")]
Inserted 1 fact(s) into 'other'.
$ +claim[("c2", 2)]
Inserted 1 fact(s) into 'claim'.
$ +other[("b2")]
Inserted 1 fact(s) into 'other'.
$ +claim[("c3", 3)]
Inserted 1 fact(s) into 'claim'.
$ +other[("b3")]
Inserted 1 fact(s) into 'other'.
$ +claim[("c4", 4)]
Inserted 1 fact(s) into 'claim'.
$ +other[("b4")]
Inserted 1 fact(s) into 'other'.
$ +claim[("c5", 5)]
Inserted 1 fact(s) into 'claim'.
$ +other[("b5")]
Inserted 1 fact(s) into 'other'.
$ +claim[("c6", 6)]
Inserted 1 fact(s) into 'claim'.
$ +other[("b6")]
Inserted 1 fact(s) into 'other'.
$ +claim[("c7", 7)]
Inserted 1 fact(s) into 'claim'.
$ +other[("b7")]
Inserted 1 fact(s) into 'other'.
# WAL inode now 7379549
(server exited after ~2s)
7 rows
7 rows
$ +claim[("c8", 8)]
Inserted 1 fact(s) into 'claim'.
(server exited after ~2s)

=== B: WAL replaced by another file while writer open ===
$ +other[("b1")]
Inserted 1 fact(s) into 'other'.
# before: inode=7379567 size=856
$ cp current.wal x && mv x current.wal   (same bytes, different file)
# after: inode=7379589 size=856
$ +other[("b2")]
write outcome unknown, store read-only until restart recovery: write the WAL file /tmp/tmp.CjxjLyebSg/data/persist/wal/current.wal was removed or moved while the server ran (the path now is a different file); undo records written to the detached file are read by restart recovery only if it is put back
# WAL after: inode=7379589 size=856
$ +other[("b3")]
store read-only until restart recovery
(server exited after ~2s)

=== C: WAL replaced while writer closed (after buffer-full flush) ===
$ +other[("b1")]
Inserted 1 fact(s) into 'other'.
$ +claim[("c1", 1), ("c2", 2)]
Inserted 2 fact(s) into 'claim'.
# after flush: inode=7379560 size=856
# replaced: inode=7379714 size=856
$ +other[("b2")]
write outcome unknown, store read-only until restart recovery: write the WAL file /tmp/tmp.CjxjLyebSg/data/persist/wal/current.wal was removed or moved while the server ran (the path now is a different file); undo records written to the detached file are read by restart recovery only if it is put back
# WAL after: inode=7379714 size=856
$ +other[("b3")]
store read-only until restart recovery
(server exited after ~2s)

2026-10-04T18:49:33.424707Z ERROR inputlayer::storage::persist::wal: WAL file removed or moved while the server ran; refusing writes until restart file=/tmp/tmp.CjxjLyebSg/data/persist/wal/current.wal now=cannot be read (No such file or directory (os error 2))
2026-10-04T18:50:03.195448Z ERROR inputlayer::storage::persist::wal: WAL file removed or moved while the server ran; refusing writes until restart file=/tmp/tmp.CjxjLyebSg/data/persist/wal/current.wal now=is a different file
2026-10-04T18:50:08.419024Z ERROR inputlayer::storage::persist::wal: WAL file removed or moved while the server ran; refusing writes until restart file=/tmp/tmp.CjxjLyebSg/data/persist/wal/current.wal now=is a different file
Evidence: Targeted test output
test write_into_a_moved_data_directory_is_recovered_when_it_is_put_back ... ok
test inserts_after_the_data_directory_is_removed_are_not_acknowledged ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s
test result: ok. 43 passed; 0 failed; 0 ignored; 0 measured; 2991 filtered out; finished in 0.05s

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ src/storage/persist/wal.rs:183 - The in-place check only covers a WAL file that stays open. Several paths close the writer and later reopen it with OpenOptions::create(true): settle() (via read_all, retain_ops, remove_shard_entries, which runs on every buffer-full flush at persist/mod.rs:796) and discard_writer. Because ensure_writer re-reads file_id from whatever is at the path when it reopens, a file removed or replaced while the writer is closed goes unnoticed. Example: a commit fills shard A's buffer, so flush_existing(A) calls remove_shard_entries, which leaves writer = None while current.wal still holds acknowledged records for unflushed shard B. If wal/current.wal is then deleted (directory left intact), the next append creates a fresh empty file, the check passes, and the write is acknowledged. Restart then silently loses B's earlier acknowledged writes. Replacing the file in that window has the same effect. Removing or moving the whole data directory, the issue's main scenario, is still caught here, because the reopen fails on the missing parent directory and nothing is acknowledged. Suggested fix: keep an in-memory expectation of whether current.wal should exist and which file it is (record the identity after settle/retain_ops' rename, clear it in clear()). In ensure_writer, if the expected file is missing or has a different device/inode, take the same read-only/OutcomeUnknown path instead of creating a new file.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 5 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Data directory removed while server runs: next fact insert returns 'outcome unknown' (not success), later writes and rule changes are refused read-only, refused row not visible ✅ pass live scenario1-data-dir-removed.txt
Data directory moved away during a write, then put back and server restarted: the uncertain write (c2) is recovered and new writes are acknowledged ✅ pass live scenario2-data-dir-moved-and-restored.txt
Adversarial (review fix): WAL file deleted while writer closed after a buffer-full flush; next insert is refused (outcome unknown) instead of acknowledged into a fresh empty WAL ✅ pass live scenario3-wal-deleted-while-writer-closed.txt
Adversarial: WAL file replaced by a different file (same bytes, new inode) with writer open, and again with writer closed; insert refused as outcome unknown, then read-only ✅ pass live scenario4-wal-replaced-and-happy-path.txt (sections B, C)
No false positive: the server's own WAL rewrites (many flushes with BUFFER_SIZE=2) keep acknowledging inserts, and all rows survive a restart ✅ pass live scenario4-wal-replaced-and-happy-path.txt (section A)
  • Built target/debug/inputlayer-server and inputlayer-client from the change and started the server on a throwaway data dir in a mktemp directory (port 18093, bootstrap API key via env), driven over WebSocket with inputlayer-client
  • Scenario 1: inserted +claim[(&#34;c1&#34;, 1)], ran rm -rf on the data dir with the server running, then inserted c2 and c3 and added a rule; then queried ?claim(C, V)
  • Scenario 2: inserted c1, mv data moved with the server running, inserted c2, stopped the server with SIGTERM, ran mv moved data, restarted, queried, inserted c3
  • Scenario 3: started the server with INPUTLAYER_STORAGE__PERSIST__BUFFER_SIZE=2, inserted into other, filled claim's buffer so the flush rewrote the WAL (inode changed) and closed the writer, ran rm current.wal with the directory left intact, then inserted again
  • Scenario 4A: with BUFFER_SIZE=2, ran 14 alternating inserts across two relations (many WAL self-rewrites), restarted, verified 7+7 rows and that a new insert succeeded
  • Scenario 4B/4C: replaced current.wal with a byte-identical copy (cp + mv, new inode), once with the writer open and once with it closed after a flush, then inserted
  • cargo test --test data_dir_loss_tests
  • cargo test --lib storage::persist::wal
⚠️ **Document** - 1 info
  • ℹ️ docs/guides/persistence.md:160 - The persistence guide exists in two full copies, docs/guides/persistence.md and docs/content/docs/guides/persistence.mdx. This change updated both the same way, but the copies already disagree elsewhere: the .md copy documents compaction_window where the .mdx documents auto_compact_threshold/auto_compact_interval_secs. Follow-up: make one of them the owner and reduce the other to a pointer or generate it.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Deferred heavy checks

Per the dev-box rule, only the checks for the touched area ran here: fmt, clippy, all lib tests (3030), the WAL unit tests, the new engine-level tests/data_dir_loss_tests.rs, and the persistence integration suites (persist_crash, persist_roundtrip, crash_recovery_tests, atomic_program_tests, restart_equivalence, data_dir_lock_tests). An end-to-end WS repro against a release build confirmed outcome_unknown then store_read_only after rm -rf or mv of the data dir. The make perf-gate same-host performance gate is deferred to the benchmark machine at the release checkpoint. The hot-path cost is one stat per WAL append and one per flush rewrite, with no new locks.

jsam added 3 commits October 4, 2026 18:38
…ed or moved (#93)

An open WAL file outlives its path: when the data directory was removed or
moved while the server ran, appends and fsyncs to the open file kept
succeeding and inserts were acknowledged, but restart recovery reads
wal/current.wal and never sees them.

After each append the WAL now checks that the file it wrote is still the one
at its path (device and inode). If not, the commit returns OutcomeUnknown
(the record sits in the detached file, recovered only if it is put back) and
the store refuses every later write with StoreReadOnly until restart. The
directory is not recreated, since writes acknowledged earlier may have been
lost with it.
@jsam
jsam merged commit 64f55e6 into main Oct 4, 2026
20 checks passed
@jsam
jsam deleted the fm/en-en1 branch October 4, 2026 18:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant