Summary
Database::batch().commit() performs two separate fallible steps against
the journal:
journal_writer.write_batch(...) — writes the batch's start marker,
items, and end marker to the journal file.
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.
- 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.
- 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(()).
- Drop the
Database and reopen it at the same path.
- 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).
Summary
Database::batch().commit()performs two separate fallible steps againstthe journal:
journal_writer.write_batch(...)— writes the batch's start marker,items, and end marker to the journal file.
journal_writer.persist(mode)— optional, only if a durability mode wasrequested; flushes/fsyncs.
In
commit()(src/batch/mod.rs), if step 2 fails, the database ispoisoned (
self.db.is_poisoned.poison()) and every future commit isrefused with
Error::Poisoned. If step 1 fails, the error propagatescorrectly (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 apartial, unterminated record at the journal's current tail (e.g. the batch's
Startmarker and some items written, but theEndmarker never reachedbecause a later
write_allcall in the same batch failed). Nothingtruncates or repairs that tail afterward. The next batch committed against
the same database — even with
PersistMode::SyncAll— writes its ownStart/items/Endsequence immediately after that leftover partial record,and
commit()correctly reportsOkfor it, because that batch's ownwrites genuinely succeeded.
On the next reopen,
JournalBatchReader::next()(src/journal/batch_reader.rs)replays the journal from the start. When it reaches the
Startmarkerbelonging to the first (failed) batch's partial record, it recognizes a
second
Entry::Startarriving before anEntry::Endas corruption("found batch start inside batch") and truncates the file back to
last_valid_pos— the position before the failed batch. This is areasonable 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 returnOk(())for a batch whose data does notsurvive 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
Resultbeing wrong. This is about thedatabase's state after a correctly-reported failure:
persist()failuresalready poison the database (undocumented publicly, but visible in
commit()'s own branch);write_batch()failures do not, even though bothcan 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(...)?incommit(), exactly as thepersist(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
Databaseopened normally.internal
BufWriter(>=JOURNAL_BUFFER_BYTES, 8 KiB) sowrite_batch()issues a realwrite(2)itself, and force that onewrite(2)call to fail (e.g. via anLD_PRELOADinterposer onwrite,scoped to the journal fd and to exactly one call).
commit()correctlyreturns
Err.Databasehandle, commit Batch B — anordinary batch, e.g. with
durability(Some(PersistMode::SyncAll)), noinjected failure this time.
commit()returnsOk(()).Databaseand reopen it at the same path.We have a minimal, self-contained reproduction (two probe binaries plus an
LD_PRELOADshim) and a passing/failing regression test built on it. If aPR is welcome, we'd like to submit one: poison the database on the
write_batch(...)?error path incommit(), mirroring the existingpersist(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 everyWriteBatch::commit()callagainst a given
Database, checked before the commit and set on anyfailure — 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).