Skip to content

fix(consensus): keep a replica off a hole in its committed prefix - #4073

Open
krishvishal wants to merge 3 commits into
masterfrom
consensus-prefix-contiguity
Open

fix(consensus): keep a replica off a hole in its committed prefix#4073
krishvishal wants to merge 3 commits into
masterfrom
consensus-prefix-contiguity

Conversation

@krishvishal

Copy link
Copy Markdown
Member

The defect

A replica could be promoted to primary while missing operations that the cluster had already committed. The promotion checks started at the merged commit point, so they did not check for missing operations between the replica’s commit_min and that point.

For example, a replica might have executed operations 1–6 and received operations 8–10, but never received operation 7. If the cluster had committed through operation 10, the promotion scan started at 10 and missed the gap.

After promotion, the replica could serve reads and compute replies from incomplete state. It could also attempt to advance commit_min past operations it had not executed, triggering an assertion.

Fixes

Promotion coverage (shard). The coverage scan, repair requests, and repair retries now start at the lower of the merged commit point and commit_min + 1. This ensures that promotion checks include missing committed operations and that repair requests cover the same range. Replicas with a contiguous committed prefix keep the existing scan range.

Repair progress (shard). Repair sessions could remain active after their requested range was satisfied, or stop making progress when the selected peer crashed. Restarting repair required another commit, which the incomplete repair could itself prevent. Sessions now clear when their range is satisfied, switch peers after a full retry interval without a response, and restart whenever repair is still needed.

Recovery barrier (consensus). At startup, a replica records the recovered journal head as the point it must commit before accepting client requests as primary. A later view change could discard part of that journal without updating the barrier. If the replica then became primary, it would wait for discarded operations to commit and reject the requests needed to make progress. The barrier is now reassessed when the merged log is finalized at view start or a StartView log is adopted. It is lowered if the suffix was truncated and retained if the suffix survived.

Commit ordering (consensus, both planes). The commit pipeline now processes only consecutive operations starting at commit_min + 1. Previously, it could pass a later operation to advance_commit_min even though an earlier prepare was missing. A debug assertion detects this in simulation and CI. Release builds report the problem and pause commit advancement until repair fills the gap. This avoids terminating the shard task while the process continues to report itself as healthy.

Consumer offset deletion (partitions). Applying a committed DeleteConsumerOffset now logs and succeeds when the offset is already absent. An absent offset is valid: AckLevel::NoAck stores apply only on the primary, so a follower may never have received the offset, and a restart can also lose it. The previous check depended on the replica’s role when the delete was committed, which did not establish whether it had received the earlier store. Treating absence as an error could therefore fence a partition during a valid committed delete. The apply function no longer returns Result.

Simulator

The quiescence checks previously compared partition commit positions but did not compare the committed operations themselves. Content comparisons covered only metadata, where partition-focused runs often committed one operation or none.

The simulator now compares partition journal contents per namespace over the range retained by both replicas. Partition journals evict committed entries as they flush, so the check allows entries that have already been removed. It compares identity_checksum, because retransmission can change the prepare’s view and therefore its sealed checksum.

The recovery-barrier failure did not reproduce in 340 seeds across two fault profiles. A new check fails a run if a metadata primary remains in Normal status below its recovery barrier for 2000 ticks.

The simulator also reports a fenced partition task explicitly. Previously, queued frames left by an exited task could be reported as a missed wake. The missed-wake check remains in place for actual scheduling failures.

@github-actions github-actions Bot added the S-waiting-on-review PR is waiting on a reviewer label Sep 6, 2026
@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.69014% with 52 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.52%. Comparing base (59a9508) to head (d5f79a5).

Files with missing lines Patch % Lines
core/simulator/src/workload/invariants.rs 55.17% 13 Missing ⚠️
core/partitions/src/iggy_partition.rs 20.00% 10 Missing and 2 partials ⚠️
core/consensus/src/plane_helpers.rs 59.09% 9 Missing ⚠️
core/shard/src/lib.rs 92.40% 5 Missing and 1 partial ⚠️
core/simulator/src/lib.rs 66.66% 3 Missing and 2 partials ⚠️
core/shard/src/router.rs 0.00% 3 Missing ⚠️
core/simulator/src/bin/workload-fuzz.rs 0.00% 2 Missing ⚠️
core/simulator/src/workload/state_checker.rs 93.33% 2 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             master    #4073       +/-   ##
=============================================
- Coverage     85.52%   70.52%   -15.00%     
  Complexity     1402     1402               
=============================================
  Files          1240     1239        -1     
  Lines        186294   167121    -19173     
  Branches     152599   133426    -19173     
=============================================
- Hits         159327   117863    -41464     
- Misses        22914    45272    +22358     
+ Partials       4053     3986       -67     
Components Coverage Δ
Rust Core 67.48% <81.69%> (-18.98%) ⬇️
Java SDK 67.29% <ø> (ø)
C# SDK 76.33% <ø> (ø)
Python SDK 90.06% <ø> (ø)
PHP SDK 85.65% <ø> (ø)
Node SDK 96.24% <ø> (ø)
Go SDK 69.35% <ø> (+0.03%) ⬆️
Files with missing lines Coverage Δ
core/consensus/src/impls.rs 91.86% <100.00%> (+0.35%) ⬆️
core/simulator/src/workload/oracle.rs 66.37% <100.00%> (+0.40%) ⬆️
core/simulator/src/bin/workload-fuzz.rs 0.00% <0.00%> (ø)
core/simulator/src/workload/state_checker.rs 94.33% <93.33%> (-0.17%) ⬇️
core/shard/src/router.rs 77.97% <0.00%> (+0.82%) ⬆️
core/simulator/src/lib.rs 96.24% <66.66%> (-0.28%) ⬇️
core/shard/src/lib.rs 82.93% <92.40%> (+1.21%) ⬆️
core/consensus/src/plane_helpers.rs 94.01% <59.09%> (-0.61%) ⬇️
core/partitions/src/iggy_partition.rs 89.74% <20.00%> (-0.40%) ⬇️
core/simulator/src/workload/invariants.rs 81.70% <55.17%> (-14.52%) ⬇️

... and 313 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread core/shard/src/lib.rs
// The quiet peer may be the thing that died, and nothing else
// re-targets a journal-repair session, so retrying it forever pins
// the walk while the rest of the cluster is serveable.
let peer = next_repair_peer(consensus.replica_count(), consensus.replica(), peer);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

session.peer is never updated. The rotated value is a shadowed local, so every retry rotates from the original peer and the RepairDone continuation (line 5097) still sends to it.

Rotation is also blind and immediate. A Normal backup rotates on the first stall to any replica, including one lagging below from_op. on_request_prepares (4683-4708) answers a range it never held with RangeEvicted and RepairDone, and the RangeEvicted arm (5106) arms a state transfer against that peer without checking retained_from against commit_min + 1. On the primary-elect path the rotation leaves the pending_view_body_sources set and hits the same conversion mid view change.

Write session.peer. Reuse next_transfer_peer (2518, prefers the primary) instead of adding next_repair_peer. Rotate only after a retry budget, as tick_partitions does (7148). Keep primary-elect rotation inside pending_view_body_sources.

Comment thread core/shard/src/lib.rs
// applied and durable in the snapshot.
let repair_floor = journal.handle().snapshot_op();
let missing = first_op_not_covered(&pending, repair_floor, |op| {
let missing = first_op_not_covered(&pending, repair_floor, consensus.commit_min(), |op| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This scan now reports a missing_op below pending.commit_max, but pending_view_body_sources(missing_op) (5829) only looks at DVC suffixes, which span commit..=op per sender. index_of returns None below that, the source list comes back empty, and the view change stalls until the timeout escalates. Every sender with commit >= missing_op holds or has compacted the op.

For missing_op < pending.commit_max, select DVC senders with commit >= missing_op, most recent log_view first. A RangeEvicted from such a peer means this replica cannot serve the committed prefix, so let the view-change timeout escalate rather than arm a state transfer as primary-elect. Test against a real DVC quorum.

}
let barrier = barrier.min(head);
self.recovery_barrier
.set(if barrier <= self.commit_max.get() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Collapsing the barrier to 0 when barrier <= commit_max opens the HTTP read gate early. await_recovery_barrier (core/server/src/http/reads.rs:279) gates on commit_min because adoption advances commit_max before applying the suffix. With head 105 and commit_min 100, new readers see barrier 0 and serve state from before ops 101..=105 apply. is_caught_up_primary compares commit_max >= barrier and needs no zero.

Set barrier.min(head) and drop the collapse. Update given_a_discarded_suffix_when_adopting_a_view_should_lower_the_barrier to expect 105 and assert commit_max() >= recovery_barrier().

.filter(|header| header.op <= commit)
.filter(|header| header.op <= commit)?;
if head.op != next {
// Unreachable in debug and the simulator; release reports and waits.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"Unreachable in debug and the simulator" is false on the partition plane. IggyPartition::commit_journal walks at most COMMIT_WALK_OPS_MAX (64) ops per call, and a promoted primary's pipeline is seeded from merged.commit_max + 1. A primary-elect whose journal covers the merged log but whose apply lags by more than 64 ops passes the coverage scan, starts the view, and receives quorum acks for commit_max + 1 before the sweep drains the backlog. drain_committable_prefix then sees head_op > commit_min + 1 and the assert fires. Release logs an error per ack until the walk catches up.

Finish the journal walk before the pipeline drain on partition promotion, or downgrade the asserts at 466 and 529 to a log.

Comment thread core/simulator/src/lib.rs
) -> Option<PrepareHeader> {
let shard = self.replicas[replica_idx].partition_shard(namespace);
let partition = shard.plane.partitions().get_by_ns(&namespace)?;
partition.log.journal().inner.header_by_op(op)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

header_by_op reads resident headers only. evict_prefix clears them on flush and moves the entries to the repair ring. After every replica flushes, assert_partition_prefixes_agree compares zero ops and the check is vacuous at quiescence.

Use repair_headers_in(1..=commit_min) once per replica per namespace, since state_checker.rs:333 probes op by op and both lookups are linear. Add a post-flush comparison test.

/// Expected wherever the paired `AckLevel::NoAck` store never replicated, so a
/// diagnostic and not a fault. Still logged: on a replica that did serve the
/// store it is the first symptom of a lost apply.
fn log_absent_offset_delete(&self, kind: &str, id: u64) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Patch coverage on this file is 20%. Add a unit test that commits DeleteConsumerOffset for an absent offset on a primary and asserts the partition stays unfenced.

@github-actions github-actions Bot added S-waiting-on-author PR is waiting on author response and removed S-waiting-on-review PR is waiting on a reviewer labels Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-waiting-on-author PR is waiting on author response

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants