Skip to content

WriteBatch::commit() does not poison the database on a write_batch() journal-write failure, unlike a persist() failure #308

Description

@russfellows

Summary

Database::batch().commit() performs two separate fallible steps against
the journal:

  1. journal_writer.write_batch(...) — writes the batch's start marker,
    items, and end marker to the journal file.
  2. journal_writer.persist(mode) — optional, only if a durability mode was
    requested; flushes/fsyncs.

In commit() (src/batch/mod.rs), if step 2 fails, the database is
poisoned (self.db.is_poisoned.poison()) and every future commit is
refused with Error::Poisoned. If step 1 fails, the error propagates
correctly (this is the WB-F02 fix, #304 — thank you for that one), but the
database is not poisoned.

This matters because a mid-batch failure in write_batch() can leave a
partial, unterminated record at the journal's current tail (e.g. the batch's
Start marker and some items written, but the End marker never reached
because a later write_all call in the same batch failed). Nothing
truncates or repairs that tail afterward. The next batch committed against
the same database — even with PersistMode::SyncAll — writes its own
Start/items/End sequence immediately after that leftover partial record,
and commit() correctly reports Ok for it, because that batch's own
writes genuinely succeeded.

On the next reopen, JournalBatchReader::next() (src/journal/batch_reader.rs)
replays the journal from the start. When it reaches the Start marker
belonging to the first (failed) batch's partial record, it recognizes a
second Entry::Start arriving before an Entry::End as corruption
("found batch start inside batch") and truncates the file back to
last_valid_pos — the position before the failed batch. This is a
reasonable response if the torn record were the last thing in the file (the
classic partial-write-before-a-crash case), but here it isn't: a
successfully committed, later batch is sitting right after it. That batch
is discarded along with the corruption, silently — no error is surfaced
anywhere in this path.

Net effect: commit() can return Ok(()) for a batch whose data does not
survive the next restart, with no diagnostic of any kind, for a batch that
had nothing wrong with it — the corruption belongs entirely to an earlier,
already-failed batch.

Why this is a different bug from #304 (WB-F02)

#304 was about a single batch's own Result being wrong. This is about the
database's state after a correctly-reported failure: persist() failures
already poison the database (undocumented publicly, but visible in
commit()'s own branch); write_batch() failures do not, even though both
can leave the same class of journal-tail damage behind. The fix, if it's
the right one from a maintainer's perspective, looks like: poison on any
error from journal_writer.write_batch(...)? in commit(), exactly as the
persist(mode) branch already does a few lines below it.

We are not asking for a rollback/repair mechanism — poisoning (refuse
further writes; let the caller restart/reopen) is the existing, already-
accepted contract for the sibling failure case, and would close this gap
with a one-line change in the same function.

Repro

Environment: Linux, fjall 3.1.8, a Database opened normally.

  1. Commit Batch A with a value large enough to bypass the journal writer's
    internal BufWriter (>= JOURNAL_BUFFER_BYTES, 8 KiB) so
    write_batch() issues a real write(2) itself, and force that one
    write(2) call to fail (e.g. via an LD_PRELOAD interposer on write,
    scoped to the journal fd and to exactly one call). commit() correctly
    returns Err.
  2. Immediately after, on the same Database handle, commit Batch B — an
    ordinary batch, e.g. with durability(Some(PersistMode::SyncAll)), no
    injected failure this time. commit() returns Ok(()).
  3. Drop the Database and reopen it at the same path.
  4. Batch B's key is absent.

We have a minimal, self-contained reproduction (two probe binaries plus an
LD_PRELOAD shim) and a passing/failing regression test built on it. If a
PR is welcome, we'd like to submit one: poison the database on the
write_batch(...)? error path in commit(), mirroring the existing
persist(mode) branch, plus the regression test (adapted from our own,
which currently lives against our downstream fork of the reproduction —
happy to rewrite it against fjall's own test harness/conventions instead).
Let us know and we'll open it.

How we're working around this in the meantime

Until this lands (or in case the fix takes a different shape than a plain
poison call), we've added an equivalent guard in our own application layer:
a shared Arc<AtomicBool> latch around every WriteBatch::commit() call
against a given Database, checked before the commit and set on any
failure — refusing all further commits against that database until the
process restarts. This gives us the same fail-closed behavior persist()
failures already get from fjall itself, just applied one layer up since we
can't set fjall's own internal poison flag from outside the crate. It's a
stopgap, not a substitute for the fix above — it only protects call sites
we've wrapped ourselves, and every other fjall consumer without an
equivalent layer is still exposed.

Version

fjall 3.1.8 (also present, by inspection, in any version since the #304 fix
landed — the poisoning gap is orthogonal to that fix).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions