Summary
When append() or fsync() fails mid-commit (ENOSPC, EIO, simulated fault), the WAL can contain a torn/partial record. transact() re-throws, but the database continues accepting writes, appending fresh records after the garbage. On next open(), recover() stops at the torn record and truncates everything after it — including commits whose transact() had already returned success and fsync'd.
This directly falsifies the headline durability claim in README and docs/RELIABILITY.md: "a returned transact() is durable" and "a crash can only ever damage the last record".
Audit ID: B3 (High) — pre-announcement blocker
Found independently by 4 audit dimensions.
Impact
- Phantom durability: successful commits lost after unrelated later IO error + reopen.
- Comment at
core.ts:503-505 ("memory and disk still agreeing on the prior state") is wrong for partial writes.
- ~20-line reproduction exists (see audit notes).
Root cause chain
log.append() → file.append(encodeRecord(ops)); file.fsync() (src/core.ts:435-437)
- Partial
writeSync can leave torn bytes; fsync may still run or fail afterward.
- No latch: next
transact() appends after the torn tail.
recover() breaks at torn record, truncates tail (core.ts:406-413).
Adapters: src/adapter/node-fs.ts:36-41 (append loops, but failure mid-loop still possible).
Proposed fix (choose one; both are industry-standard)
Option A — Failed-state latch (recommended; SQLite/Postgres-like)
On any append or fsync failure while a log is attached:
- Set
failed = true on the database instance.
- Every subsequent
transact() throws until close() + new open().
- Do not swap
committed if append fails (verify current code path).
Option B — Truncate-back before next write
Before accepting the next append, truncate the file to the last known-good record boundary (pre-failed-append offset). Harder to get right; requires tracking durable offset.
Regardless: never continue appending after a failed fsync ("fsyncgate").
Tests (mandatory)
Requires SimFS extension (see DST IO-error issue) OR minimal inline fault injection:
- Commit A (success) → arm append fault on commit B (partial write) → B throws → commit C attempted → must throw (latch) OR must not destroy A on reopen.
- Reopen → state equals post-A, not empty/truncated past A.
Until SimFS supports this, add armAppendError / armFsyncError in the DST issue — this issue can land with a minimal SimFS hook.
Related issues
Acceptance criteria
Agent constraints
- Guarded-core change; keep latch logic small and readable.
- Do not weaken default fsync-per-commit semantics.
Key files
src/core.ts — openLog, transact, possibly Log interface state
src/sim/simfs.ts — fault injection hooks (may be shared with DST issue)
docs/RELIABILITY.md — after fix, align claims (or defer to B11)
Verification
Summary
When
append()orfsync()fails mid-commit (ENOSPC, EIO, simulated fault), the WAL can contain a torn/partial record.transact()re-throws, but the database continues accepting writes, appending fresh records after the garbage. On nextopen(),recover()stops at the torn record and truncates everything after it — including commits whosetransact()had already returned success and fsync'd.This directly falsifies the headline durability claim in README and
docs/RELIABILITY.md: "a returned transact() is durable" and "a crash can only ever damage the last record".Audit ID: B3 (High) — pre-announcement blocker
Found independently by 4 audit dimensions.
Impact
core.ts:503-505("memory and disk still agreeing on the prior state") is wrong for partial writes.Root cause chain
log.append()→file.append(encodeRecord(ops)); file.fsync()(src/core.ts:435-437)writeSynccan leave torn bytes; fsync may still run or fail afterward.transact()appends after the torn tail.recover()breaks at torn record, truncates tail (core.ts:406-413).Adapters:
src/adapter/node-fs.ts:36-41(append loops, but failure mid-loop still possible).Proposed fix (choose one; both are industry-standard)
Option A — Failed-state latch (recommended; SQLite/Postgres-like)
On any append or fsync failure while a log is attached:
failed = trueon the database instance.transact()throws untilclose()+ newopen().committedif append fails (verify current code path).Option B — Truncate-back before next write
Before accepting the next append, truncate the file to the last known-good record boundary (pre-failed-append offset). Harder to get right; requires tracking durable offset.
Regardless: never continue appending after a failed fsync ("fsyncgate").
Tests (mandatory)
Requires SimFS extension (see DST IO-error issue) OR minimal inline fault injection:
Until SimFS supports this, add
armAppendError/armFsyncErrorin the DST issue — this issue can land with a minimal SimFS hook.Related issues
Acceptance criteria
src/core.test.tsand/orsrc/sim/dst.ts.core.ts:503-505.bun run gategreen.Agent constraints
Key files
src/core.ts—openLog,transact, possiblyLoginterface statesrc/sim/simfs.ts— fault injection hooks (may be shared with DST issue)docs/RELIABILITY.md— after fix, align claims (or defer to B11)Verification