Skip to content

fix(dynostore): fix 3 partial-failure bugs found by object_store fault injection - #805

Merged
shortishly merged 17 commits into
mainfrom
742-idempotent-produce-on-the-object-store-engine-can-lose-a-write-and-tell-the-client-it-was-stored
Oct 14, 2026
Merged

shortishly merged 17 commits into
mainfrom
742-idempotent-produce-on-the-object-store-engine-can-lose-a-write-and-tell-the-client-it-was-stored

Conversation

@shortishly

@shortishly shortishly commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

All three shared the same shape: commit a state transition durably in one object-store write, then do more non-atomic work, with the retry path keyed off "was I in the pre-transition state" rather than "did the work actually finish" - so a transient failure got silently treated as fully complete.

  • produce(): the idempotent sequence number advanced durably before the record write that it's meant to guard. A failure writing the record left the sequence advanced anyway, so the client's mandatory retry was told DuplicateSequenceNumber - permanent, silent data loss. Fixed by splitting the check (read-only, unchanged position) from the durable advance, which now only commits after the record write succeeds.

  • txn_end(): the transaction state moved from Begin to PrepareCommit before the per-partition end-of-transaction marker writes. A failure partway through left the retry recomputing an empty set of partitions needing a marker (since it was no longer in Begin), completing the transaction anyway with some partitions never marked - never marked, so read_committed consumers of those partitions stall at the transaction's first offset. Fixed by recomputing the set from whatever's still in txn_detail.produces whenever in a Prepare* state, not only on the Begin transition.

  • delete_topic(): topic metadata was removed before the two delete_stream cleanup calls. A failure partway left a retry finding no metadata and reporting UnknownTopicOrPartition without retrying anything, orphaning the partition's record objects at a name-keyed path forever - blocking any topic later recreated with the same name (AlreadyExists, permanently). Fixed by removing metadata last, only once cleanup actually succeeds.

Each fix accepts a trade-off in exchange for closing the original permanent/silent failure:

  • produce(): a duplicate instead of a loss. If the sequence advance fails after the record write succeeds (any transient failure, or a shutdown aborting the request between the two), the client's retry passes the pre-check and the batch is written a second time. This is not limited to a race. The pre-check can also pass on stale state (a cached meta.json on another broker, or a retry while the original put is still in flight). Failures of the post-write advance are now logged at error! and counted in nisshi_produce_post_write_sequence_rejected.
  • txn_end(): a harmless second marker on retry. A retry whose decision contradicts the prepared state (commit, then abort) is rejected with InvalidTxnState.
  • delete_topic(): a slightly larger window where a concurrent produce() or offset_commit() can land on a mid-delete topic.

Not fixed here, tracked as follow-ups:

What does this change do, and why?

Fixes #742 (produce), and fixes the same class of bug in txn_end and delete_topic.

How was this tested?

traceforge_* fault-injection tests (each fails with the fix reverted), delete_topics, and the broker, dynostore and storage-sql suites; just clippy and cargo fmt --check are clean. The nisshi-schema berg tests need the lakehouse service and were not run.

Checklist

  • Commits are signed off (git commit -s) — see CONTRIBUTING.md
  • just fmt, just clippy, and just test pass locally
  • Tests added or updated for the behavior changed
  • Docs updated if user-facing behavior changed

…t injection

All three shared the same shape: commit a state transition durably in one
object-store write, then do more non-atomic work, with the retry path
keyed off "was I in the pre-transition state" rather than "did the work
actually finish" - so a transient failure got silently treated as fully
complete.

- produce(): the idempotent sequence number advanced durably before the
  record write that it's meant to guard. A failure writing the record
  left the sequence advanced anyway, so the client's mandatory retry was
  told DuplicateSequenceNumber - permanent, silent data loss. Fixed by
  splitting the check (read-only, unchanged position) from the durable
  advance, which now only commits after the record write succeeds.

- txn_end(): the transaction state moved from Begin to PrepareCommit
  before the per-partition end-of-transaction marker writes. A failure
  partway through left the retry recomputing an empty set of partitions
  needing a marker (since it was no longer in Begin), completing the
  transaction anyway with some partitions never marked - invisible to
  read_committed consumers forever. Fixed by recomputing the set from
  whatever's still in txn_detail.produces whenever in a Prepare* state,
  not only on the Begin transition.

- delete_topic(): topic metadata was removed before the two delete_stream
  cleanup calls. A failure partway left a retry finding no metadata and
  reporting UnknownTopicOrPartition without retrying anything, orphaning
  the partition's record objects at a name-keyed path forever - blocking
  any topic later recreated with the same name (AlreadyExists,
  permanently). Fixed by removing metadata last, only once cleanup
  actually succeeds.

Each fix accepts a narrower, pre-existing-adjacent trade-off in exchange
for closing the original permanent/silent failure (documented at each
site): a rare residual duplicate-on-race for produce(), a harmless second
marker for txn_end(), and a slightly larger window where a concurrent
produce() can land on a mid-delete topic for delete_topic().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>

@solace-aross solace-aross left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. All three fixes close real permanent failures, and the regression tests earn their green.

What I checked, by running it (not reading)

Check Result
traceforge_* tests at PR head (-p nisshi-broker --features dynostore) 12/12 pass, under 2s total
Same 3 *_object_store_fault tests with dynostore.rs reverted to main's logic (keeping only the pub changes) 3/3 fail: :425 (record count), :630 (partition 1 has no marker), :273 (recreated topic can't produce)

So each test genuinely couples to its fix.

Two residuals are worth knowing about. Neither blocks, because each is less harmful than the bug it replaces. I reproduced both with throwaway tests using the same FaultInjectingObjectStore pattern (not pushed).

1. produce() now trades silent loss for a duplicate on any transient failure, not only on a race. Fault the meta.json write that advances the sequence after the record write succeeds, then retry the same batch:

attempt1        = Err(ObjectStore(Generic { .. "meta advance failed" }))
attempt2 (retry) = Ok(1)
fetched records = 2

The pre-check still sees the old sequence, so the retry is accepted and written again at a new offset. On main the same fault writes nothing and the retry is fine, so this is a new mode. Duplicate beats loss, so I agree with the direction. The code comment at :1040 and the commit message describe the residual as "duplicate-on-race" only, though, and that undersells it. Worth correcting the wording and tracking a follow-up. One way to close it: on a sequence mismatch, check whether the tail batch at the partition already carries this producer_id/base_sequence before accepting the retry. That's roughly how Kafka rebuilds producer state from the log.

2. txn_end accepts a retry with the opposite decision. Commit with partition 1's marker faulted, then txn_end(.., committed: false):

abort retry = Ok(None)
partition 0 control markers = 2   (commit, then abort)
partition 1 control markers = 1   (abort)

Partition 0 commits and partition 1 aborts, which breaks atomicity. This is pre-existing: main also returns Ok(None) there (partition 0 commit, partition 1 no marker). But this PR makes Prepare* an intentional retry path, so it's the natural place to reject a decision that contradicts the prepared direction. Kafka returns INVALID_TXN_STATE for this.

Smaller notes

  • DynoStore and its builder are now pub in a published crate, only so tests can inject an ObjectStore. #[doc(hidden)] or a test-support feature would keep that out of the public API.
  • About 2,400 of the added test lines (traceforge_spike, _group_join, _offset_commit, _produce_idempotent) don't relate to the three fixes. Seven module docs also point at docs/traceforge-test-plan.md, which isn't in the repo. Either add the plan or drop the references, so the next reader isn't sent to a missing file.
  • The commit has no Signed-off-by line (CONTRIBUTING asks for DCO sign-off).

Comment thread nisshi-storage-dynostore/src/dynostore.rs
Comment thread nisshi-storage-dynostore/src/dynostore.rs
sgamelin
sgamelin previously approved these changes Sep 30, 2026

@sgamelin sgamelin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The three reorderings are right, and I agree with @solace-aross that a duplicate beats a loss. I've left three inline comments on points the existing review doesn't cover. Two more are about txn_end code outside the diff:

1. Transactional consumer offsets have the same bug, about 100 lines below the fix. The CAS that completes the transaction sets the state to Committed (:2726) and clears txn_detail.offsets (:2761). Only after that does offset_commit run (:2792). Its per-partition result is thrown away (_ = ...), and offset_commit reports a failed put as UnknownServerError inside Ok (:1496). So EndTxn returns success even when the sendOffsetsToTransaction offsets were never written. A retry finds the transaction Committed with no offsets left, so it has nothing to redo. In a consume-transform-produce app, that means input gets reprocessed and output duplicated. This predates the PR, so a follow-up is fine. The fix is to check the codes, and to clear offsets in a CAS only after the writes succeed.

2. A transaction stuck in Prepare* recovers only if the same epoch retries EndTxn. A producer that restarts after a failed EndTxn calls InitProducerId instead. init_producer rolls back only Begin (:2169), so it bumps the epoch and abandons the old transaction with its markers still missing. offset_stage counts Prepare* as open, so read_committed consumers on those partitions stall at the transaction's first offset. Could init_producer finish a Prepare* transaction before bumping the epoch, or could that go on a follow-up? Also, the description says the old bug left records "invisible to read_committed consumers forever". That doesn't quite match dynostore, which computes the last stable offset from transaction state, not from markers. The symptom to expect now is a stalled consumer.

One addition to the thread at :1054: on shutdown, accept_loop breaks and drops its JoinSet (nisshi-service/src/stream.rs:342), which aborts in-flight produces. So a rolling deploy under load can routinely stop a produce between the record put and the sequence advance. That makes the dedupe follow-up more urgent than a fix for a rare fault.

Comment thread nisshi-storage-dynostore/src/dynostore.rs
Comment thread nisshi-storage-dynostore/src/dynostore.rs Outdated
Comment thread nisshi-storage-dynostore/src/dynostore.rs
shortishly and others added 5 commits October 5, 2026 09:21
…e-on-the-object-store-engine-can-lose-a-write-and-tell-the-client-it-was-stored

Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
The file was never committed to the repo; inline the explanations the
six traceforge test files' doc comments pointed at it for instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
update_group_conditional::concurrent_same_version already covers the same
two-writer race with real concurrency across every backend, so the spike
found nothing beyond what it set out to prove the technique works.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
…pport

DynoStore::new and its builder methods were pub only so nisshi-broker's
fault-injection tests could supply a custom ObjectStore; production code
only ever calls them from within the crate via factory.rs. Make them
pub(crate) by default and pub only under a test-support feature, enabled
as a dev-dependency feature in nisshi-broker, so they no longer appear in
the published crate's public API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
Neither Phase 1 (group_join) nor Phase 2 (produce_idempotent) actually
surfaced a production bug - only the Phase 3 fault-injection tests did
(a982f04), so the comparison here didn't hold up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
solace-aross
solace-aross previously approved these changes Oct 5, 2026

@solace-aross solace-aross left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three fixes share the same root cause (a durable state transition committed before the non-atomic work it's meant to guard) and all three are fixed the same way: reorder so the durable commit only happens after the work it depends on has actually succeeded.

Checked the produce() split directly: the pre-check is now read-only (.with, not .with_mut) and the durable sequence advance happens after the record write succeeds, re-checking rather than blindly re-applying, which correctly handles a concurrent write landing in between. delete_topic() now removes metadata last, after both cleanup calls, so a retry after a transient failure finds the topic again instead of orphaning its records. txn_end() recomputes the pending-partition set from txn_detail.produces on every Prepare* pass, not just the Begin transition, so a partial failure gets retried instead of silently completing with partitions unmarked.

The new pub(crate) -> pub visibility bump on DynoStore looks like it's there to let the new traceforge_* fault-injection tests in nisshi-broker/tests/it/ construct one directly - makes sense for an integration test, not a concern.

CI is green including the new fault-injection tests for two of the three bugs. Approving.

shortishly and others added 2 commits October 6, 2026 11:55
…e-on-the-object-store-engine-can-lose-a-write-and-tell-the-client-it-was-stored

Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>

# Conflicts:
#	Cargo.lock
- produce: correct the comment on the post-write sequence advance. A
  failed advance after the record write causes a duplicate on retry for
  any transient failure, not only a race.
- produce: log post-write Duplicate/OutOfOrder rejections at error! with
  offset, location, producer_id and base_sequence, and count them in
  nisshi_produce_post_write_sequence_rejected.
- txn_end: reject a retry whose decision contradicts the prepared state
  with InvalidTxnState, so a commit cannot be followed by abort markers.
- test: add end_txn_retry_with_opposite_decision_is_rejected.
- use std::assert_matches instead of the assert_matches crate, drop the
  dependency, and record the convention in CLAUDE.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
@shortishly
shortishly dismissed stale reviews from solace-aross and sgamelin via c94e529 October 8, 2026 07:24
shortishly and others added 3 commits October 8, 2026 08:33
…e-on-the-object-store-engine-can-lose-a-write-and-tell-the-client-it-was-stored

Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>

# Conflicts:
#	Cargo.lock
…e-on-the-object-store-engine-can-lose-a-write-and-tell-the-client-it-was-stored

Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>

# Conflicts:
#	nisshi-broker/tests/it/delete_topics.rs
#	nisshi-storage-dynostore/src/dynostore.rs
An acks=0 produce now fails the request on any partition error instead of
returning per-partition error codes, which these tests assert on.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
solace-aross
solace-aross previously approved these changes Oct 9, 2026

@solace-aross solace-aross left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving after c94e529; my earlier approval was dismissed by the push. Both of my notes are addressed.

Checked in the delta:

  • The produce comment now states the residual accurately: a failed sequence advance after the record write duplicates on retry for any transient failure, not only a race. The dedupe against the partition tail batch is a named follow-up.
  • txn_end returns InvalidTxnState when a retry contradicts the prepared state (PrepareCommit with abort, PrepareAbort with commit), before any markers are written. end_txn_retry_with_opposite_decision_is_rejected covers the faulted-marker, opposite-decision, then same-decision sequence.
  • Post-write Duplicate/OutOfOrder rejections now log offset, location, producer_id and base_sequence at error and increment nisshi_produce_post_write_sequence_rejected.
  • The assert_matches crate swap to std::assert_matches touches only manifests and test code.

Notes:

  1. Open from sgamelin and still worth a tracked issue before this ships widely: the pre-check can pass on stale state (the 5s metadata cache across brokers), so a fenced producer's batch can be written before the later check rejects it. On main the CAS ran first. The author's reply agrees and defers it, which I accept for the dynostore engine as it stands, since the new ordering removes a permanent loss.
  2. The offset_commit window against a topic that is mid-delete, and the transactional offsets in txn_end being dropped when the write fails, are pre-existing and already acknowledged as follow-ups.
  3. No test covers the new counter.

I did not rerun the fault-injection tests locally this pass; the new check and logging are read from the diff, and CI is green on 1e5db7a.

…e-on-the-object-store-engine-can-lose-a-write-and-tell-the-client-it-was-stored

Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
solace-aross
solace-aross previously approved these changes Oct 9, 2026

@solace-aross solace-aross left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving at 739bb9a. Since 1e5db7a the branch gained two merges of main and b22512e, which moves the traceforge produce tests from acks=0 to acks=1. That matches #845, where an acks=0 produce now fails the request instead of returning per-partition error codes. The PR's own diff is otherwise unchanged apart from a comment-context shift from #889.

My earlier note stands: sgamelin's stale pre-check point is still deferred by the author. CI is green on this head.

…e-on-the-object-store-engine-can-lose-a-write-and-tell-the-client-it-was-stored

Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>

# Conflicts:
#	CLAUDE.md
@shortishly
shortishly force-pushed the 742-idempotent-produce-on-the-object-store-engine-can-lose-a-write-and-tell-the-client-it-was-stored branch from f4466a7 to 2afdbc3 Compare October 12, 2026 14:12
…e-on-the-object-store-engine-can-lose-a-write-and-tell-the-client-it-was-stored

Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
sgamelin
sgamelin previously approved these changes Oct 13, 2026

@sgamelin sgamelin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at d080ea7. The PR's own diff is identical to 739bb9a, the head of the last approval. Since then the branch was re-signed and merged with main, and all seven commits now carry Signed-off-by. The only change on main in the same code is #887, which touches init_producer and doesn't overlap the three fixes. I reran the traceforge_* and delete_topics tests on this head: 26 of 26 pass.

The commits since my earlier review read well. The produce comment now describes the residual accurately. A retry with the opposite decision returns InvalidTxnState, which matches Kafka 3.9.1 (TransactionCoordinator.scala#L554-L563). Gating the constructor behind test-support keeps it out of the published API.

Two asks, neither blocking:

  1. Could you file the follow-ups before this merges? Fixes #742 closes the issue on merge, but the "Not fixed here" items in the description have no issues yet:

    • dedupe of produce retries against the partition's tail batch, or a pending-sequence CAS before the watermark bump;
    • a "deleting" tombstone for delete_topic, checked by produce, offset_commit and fetch;
    • transactional consumer offsets lost when offset_commit fails inside EndTxn;
    • init_producer abandoning a transaction stuck in Prepare* (after #887 it still rolls back only Begin).

    Without them, nothing tracks the duplicate-on-retry residual once #742 closes.

  2. One inline comment on the new counter.

Comment thread nisshi-storage-dynostore/src/dynostore.rs Outdated
…e-on-the-object-store-engine-can-lose-a-write-and-tell-the-client-it-was-stored

Conflicts:
	nisshi-broker/Cargo.toml
	nisshi-storage-dynostore/src/dynostore.rs

Cargo.toml: keep both dev-dependencies (crc-fast from main, test-support and
traceforge from this branch).

dynostore.rs: DynoStore::new now returns Result<Self> as on main, with this
branch's test-support visibility split kept around it. In produce, main's
decode-and-validate step (#853) runs before the idempotent sequence
pre-check, and this branch's post-write sequence advance is unchanged. The
four traceforge call sites handle the new Result.

Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
StorageType, storage_container and Url are used only by the turso-gated
module, so a build without the turso feature (just build-all) warned about
unused imports.

Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
…ason

The counter covered only DuplicateSequenceNumber and OutOfOrderSequenceNumber,
but any error from the post-write sequence advance means the batch is stored
while the client is told it failed. The two errors most likely in practice
were missed: a transient object store error (the retry then writes the batch
a second time) and ProducerFenced (the pre-check passed on stale metadata).

Count every error, with a reason attribute: the Kafka error name for an API
error, the object store error name for an object store error, and other for
anything else, which keeps the cardinality bounded. Rename the metric to
nisshi_produce_post_write_advance_failed, as it no longer counts only
rejected sequences. It is new in this PR, so nothing reads the old name.

An end-to-end test fails the meta.json write that follows a record write and
checks the produce is reported as failed, the batch is in the log and the
counter is 1 with its reason. Unit tests cover the reason mapping.

Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>

@solace-aross solace-aross left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 10918fa. Sam's open point is closed: nisshi_produce_post_write_advance_failed now counts every error from the post-write advance, with a bounded reason attribute.

Checked in the delta since 739bb9a:

  • Ran the memory-backed suites at this head: 21/21 across the traceforge tests, delete_topics::in_memory and the new post-write test. Postgres is not reachable here, so CI covers those legs.
  • Narrowing the counter back to API errors only makes the new test fail, so it guards the fix.
  • git merge-tree against main is clean.

One note, not blocking: reason has two catch-all values, otherwise (an object store error the request metric does not name) and other (anything else). A line in the counter's description would save a dashboard author from reading them as a typo.

@shortishly
shortishly added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit cfedcc8 Oct 14, 2026
41 checks passed
@shortishly
shortishly deleted the 742-idempotent-produce-on-the-object-store-engine-can-lose-a-write-and-tell-the-client-it-was-stored branch October 14, 2026 10:58
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.

Idempotent produce on the object-store engine can lose a write and tell the client it was stored

3 participants