Skip to content

[P1][kernel] Latch database on append/fsync failure (fsyncgate) #19

Description

@cevheri

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

  1. log.append()file.append(encodeRecord(ops)); file.fsync() (src/core.ts:435-437)
  2. Partial writeSync can leave torn bytes; fsync may still run or fail afterward.
  3. No latch: next transact() appends after the torn tail.
  4. 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:

  1. 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.
  2. 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

  • Failed append/fsync does not leave the DB in a state where later commits mask torn data.
  • Reopen after IO error does not silently drop previously fsync'd commits.
  • Pinning test(s) in src/core.test.ts and/or src/sim/dst.ts.
  • Fix or remove the incorrect comment at core.ts:503-505.
  • bun run gate green.
  • Changeset required.

Agent constraints

  • Guarded-core change; keep latch logic small and readable.
  • Do not weaken default fsync-per-commit semantics.

Key files

  • src/core.tsopenLog, 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

bun run gate

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/kernelPre-announcement auditbugSomething isn't workingpre-announcementPre-announcement auditpriority/highPre-announcement audit

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions