Skip to content

fix(consensus): stop view change discarding committed ops - #3824

Merged
hubcio merged 11 commits into
masterfrom
vsr-dvc-headers
Aug 10, 2026
Merged

fix(consensus): stop view change discarding committed ops#3824
hubcio merged 11 commits into
masterfrom
vsr-dvc-headers

Conversation

@krishvishal

Copy link
Copy Markdown
Contributor

What this fixes

A committed, client-acknowledged op survived a primary crash only if the round-robin new primary happened to hold it.

DoViewChange carried scalars only, so the new primary took the winner's op number without its log, rebuilt its pipeline from its own journal, hit the local gap, and truncated the range as "decided lost". The op was journaled on a quorum, committed, and already replied to; the next client op reused the number and collided with the stale entry on an up-to-date backup. The comment justifying the truncation cited a DVC merge that did not exist.

DoViewChange now carries the sender's uncommitted suffix, a nack bit and a present bit per entry, and the new primary merges the quorum's headers. Discarding requires a nack quorum, which quorum intersection makes unreachable for anything that may have committed. Otherwise the view does not start: unavailable and saying so beats available and short a reply.

Changes

Independent defects on the same path.

Prepare identity. PrepareHeader.checksum was never set, so parent chained zeros and every merge comparison was 0 == 0. Now sealed on both planes and verified at all four ingress points, not just metadata repair: a frame corrupted in flight was journaled as-is and re-served, which the interior-corruption boot refusal turns into an unbootable node. The WAL scan checks it and the parent chain, since a flipped commit reaches recovery's max(header.commit) watermark. view is excluded, because restamp_prepare_view rewrites it in place.

Zero, enforced two ways. For prepares CHECKSUM_UNSEALED is 0 and means "skip", so existing WALs replay. For control headers a zero is a FrameChecksumMismatch: keying on "looks sealed" lets one flipped bit disable the layer by clearing the field that gates the check. Tolerant on disk, strict on the wire.

Control frame integrity. Canonical selection took the first sender scanned and read a differing header as an implicit nack, so one bit flipped in that sender's suffix turned every honest sender's correct header into a nack against the garbage: a nack quorum on three replicas, above commit_max where the refusal does not fire. Control headers now carry a checksum over every byte past it, verified on the typed parse before validate reads a field. Body verification keys on body presence, not on whether checksum_body looks sealed.

Merge correctness. Canonical selection consults every canonical sender and treats disagreement as undecidable, since senders at the canonical log_view cannot legitimately disagree when a primary prepares one thing per op. Only a sender behind that log_view may nack implicitly, a constraint the comment stated and nothing tested. The decoder recomputes each identity and checks view and timestamp monotonicity and the hash chain. A tripwire that panicked on same-log_view disagreement is gone; it fired on remote bytes.

Quorum sizing. Splitting the replication and view-change quorums to define the nack quorum surfaced a latent bug: at replica_count 4 both were 2, so they could be disjoint.

log_view timing. Raised when the merge parked, before the merged head was installed, so a primary-elect superseded or crashed mid-repair held a durable claim to a view whose headers it never had, then carried its own stale head as sole canonical sender of the next view change.

Parked-log roles. A backup's parked log is a verification reference, not a repair window. It doubled as the view's repair scope, so once traffic passed the view's opening head every repaired op was discarded unnamed and the backup never converged.

Latched quorum flag. Set on reaching a view-change quorum, not on deciding a log, so every non-Ready outcome was terminal: an AwaitingRepair waiting for more DoViewChange messages had guaranteed it would ignore them.

Repair serving. Bounded by the local frontier again. to_op comes from a peer with only from_op <= to_op validated and the skip loop walks op by op with no await, so on the single-threaded shard pump an unclamped u64::MAX ends the shard.

Divergence reconciliation. Journal::truncate_from drops a diverging uncommitted suffix and leaves the snapshot watermark alone. Not drain, which advances the watermark past what it removed: correct for a committed prefix, ruinous for a suffix, marking the ops that must stay refillable as evictable.

Data at rest. Disk polls verify each batch against its batch_checksum and fail closed. Consumer offset files gain a checksum, since the offset is a cursor reloaded unchanged on every restart and a flipped bit silently rewinds or skips a consumer. Bare pre-checksum files still load and upgrade on the next write, and the legacy server stays compatible both ways by reading the first eight bytes and stopping. Separable from the consensus work.

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

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.80418% with 467 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.61%. Comparing base (0102c0f) to head (5c02ff6).

Files with missing lines Patch % Lines
core/shard/src/lib.rs 86.63% 98 Missing and 16 partials ⚠️
core/binary_protocol/src/consensus/header.rs 73.79% 62 Missing and 3 partials ⚠️
core/consensus/src/impls.rs 82.77% 44 Missing and 2 partials ⚠️
core/consensus/src/view_change_quorum.rs 74.15% 41 Missing and 5 partials ⚠️
core/journal/src/prepare_journal.rs 85.18% 30 Missing and 10 partials ⚠️
core/consensus/src/dvc_merge.rs 93.61% 27 Missing and 3 partials ⚠️
core/simulator/src/deps.rs 31.25% 22 Missing ⚠️
core/server-ng/src/dispatch.rs 26.08% 16 Missing and 1 partial ⚠️
core/partitions/src/iggy_partition.rs 80.76% 12 Missing and 3 partials ⚠️
core/server-ng/src/offset_recovery.rs 0.00% 14 Missing ⚠️
... and 12 more
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3824      +/-   ##
============================================
- Coverage     76.75%   75.61%   -1.14%     
- Complexity     1021     1316     +295     
============================================
  Files          1365     1378      +13     
  Lines        172693   176614    +3921     
  Branches     142622   145744    +3122     
============================================
+ Hits         132550   133552    +1002     
- Misses        36320    39120    +2800     
- Partials       3823     3942     +119     
Components Coverage Δ
Rust Core 75.93% <86.80%> (+0.15%) ⬆️
Java SDK 66.40% <ø> (+3.18%) ⬆️
C# SDK 48.05% <ø> (-28.08%) ⬇️
Python SDK 89.98% <ø> (ø)
PHP SDK 82.97% <ø> (ø)
Node SDK 96.36% <ø> (+0.08%) ⬆️
Go SDK 69.13% <ø> (ø)
Files with missing lines Coverage Δ
core/configs/src/server_ng_config/metadata.rs 98.21% <100.00%> (+2.17%) ⬆️
core/configs/src/server_ng_config/partition.rs 95.87% <ø> (ø)
core/configs/src/server_ng_config/validators.rs 84.82% <ø> (+0.01%) ⬆️
core/consensus/src/lib.rs 0.00% <ø> (ø)
core/journal/src/lib.rs 25.00% <ø> (ø)
core/partitions/src/iggy_partitions.rs 73.75% <100.00%> (+3.65%) ⬆️
core/partitions/src/lib.rs 0.00% <ø> (ø)
core/partitions/src/types.rs 34.48% <ø> (ø)
core/server-ng/src/partition_reconciler.rs 93.02% <100.00%> (+<0.01%) ⬆️
core/simulator/src/lib.rs 97.15% <100.00%> (+0.14%) ⬆️
... and 23 more

... and 141 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

@hubcio hubcio left a comment

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.

one finding with no diff line to attach to: legacy persist_offset (core/server/src/streaming/partitions/storage.rs:146) opens without .truncate(true) and writes 8 bytes, so a downgrade over a 16-byte checksummed record leaves the stale checksum in bytes 8..16, and the next upgrade reads it as Corrupt - which lands in the offset-panic chain flagged on offset_storage.rs. the "legacy server stays compatible both ways" claim in offset_recovery.rs holds for reads, not writes. one-line fix on the legacy side, or scope the claim.

Comment thread core/shard/src/lib.rs Outdated
Comment thread core/consensus/src/view_change_quorum.rs Outdated
Comment thread core/partitions/src/offset_storage.rs Outdated
Comment thread core/partitions/src/poll_plan.rs Outdated
Comment thread core/consensus/src/dvc_merge.rs
Comment thread core/binary_protocol/src/consensus/header.rs Outdated
Comment thread core/consensus/src/impls.rs Outdated
Comment thread core/server_common/src/send_messages2.rs Outdated
Comment thread core/simulator/src/deps.rs
Comment thread core/partitions/src/offset_storage.rs
@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 Aug 7, 2026
The step started one replica against the two-node roster in
core/server-ng/config.toml and called it a single-node cluster. A
two-replica cluster commits on two acks, so the lone node journaled every
op and committed none, and every client request blocked until it timed
out. It passed before only because the replication quorum for two
replicas was f+1=1, which is not durable.
The step started one replica against the two-node roster in
core/server-ng/config.toml. replica_count is the roster length and a
two-replica cluster commits on two acks, so the lone node journalled every
op, committed none, and each client request blocked until it timed out. It
passed before only because the replication quorum for two replicas was
f+1=1, which is not durable.

Trim a copy of the config to the replica 0 entry rather than shrinking the
shipped roster, which the Node VSR lane starts both of its replicas
against.
numinnex
numinnex previously approved these changes Aug 10, 2026
replica_count is the roster length, so one process started against the shipped
two-node roster is a degraded two-node cluster, not a single-node one. It made
progress only because the old shared quorum returned 1 at two replicas, which
is a two-replica cluster committing on a single ack. Sizing the replication
quorum correctly means the lane has to start the peer it was already claiming
to have.

Roster node 1 ships a full non-colliding port set for exactly this, and the
Node VSR lane already runs both replicas against the same roster. The
tcp_address and http_address inputs on node 1 are the readiness probe target,
not a bind override: cluster mode binds each node's roster ports, and without
them the probe would poll node 0's already-open 8090 and pass without ever
seeing node 1 come up.

Replaces the generated single-node config, which trimmed a copy of the shipped
roster down to a replication quorum of one.
@hubcio
hubcio merged commit 6cbf47a into master Aug 10, 2026
103 checks passed
@hubcio
hubcio deleted the vsr-dvc-headers branch August 10, 2026 13:52
@github-actions github-actions Bot removed the S-waiting-on-author PR is waiting on author response label Aug 10, 2026
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.

3 participants