mattp5657 opened a new issue, #4075:
URL: https://github.com/apache/iggy/issues/4075
## Summary
A partition backup's replication gap check accepts or drops an incoming
Prepare based on
one comparison: whether its op number is exactly one past the local
sequencer. It has no
way to tell "I already have this, it's a stale duplicate" apart from "my
sequencer was
advanced past this during a view change, but the body never arrived." After
a view change
that inherits an uncommitted op, and once anything else advances the
sequencer past that
op, every future retransmission of it fails the same check, permanently. The
op can never
be acked, so the commit point freezes below it, and every later write is
blocked behind it,
including writes that have themselves reached full quorum. Producers get
silence until
their read timeout expires. The group does not recover on its own.
This was discovered when
```
cluster::fast_primary_rejoin::given_a_quic_producer_when_its_first_roster_hop_is_down_should_reach_the_partition_primary
```
continually failed for #3873
## Reproduction
Fails roughly 70 to 90 percent of runs locally, and on all 4 nextest
attempts in CI. Not
branch specific; reproduces on unrelated PRs.
Failing test:
```
cluster::fast_primary_rejoin::given_a_quic_producer_when_its_first_roster_hop_is_down_should_reach_the_partition_primary
```
**1. Build the server binary first**
```bash
cargo build --bin iggy-server
```
**2. Build and run the test:**
```bash
cargo test -p integration --no-run
# produces target/debug/deps/mod-<hash>
./target/debug/deps/mod-<hash> --exact \
cluster::fast_primary_rejoin::given_a_quic_producer_when_its_first_roster_hop_is_down_should_reach_the_partition_primary
```
Failing runs take about 32s. Passing runs take about 10s.
**3. Capture diagnostics on failure:**
```bash
RUST_LOG=info,iggy=debug ./target/debug/deps/mod-<hash> --exact <test>
```
Per-test logs land in
`test_logs/cluster__fast_primary_rejoin__<testname>_<hash>/`:
`test_stdout.log` (client SDK tracing) and `server_{0,1,2}_stdout.log`
(servers, including
the `iggy.sim` VSR event trace). Note a `RUST_LOG` value that filters out
the server's own
targets breaks harness startup; `info,iggy=debug` is known to work.
Failure output:
```
post-dead-roster-hop: an attempt outlived the whole 30s budget
(0/1 acked, 2 attempts, last error: Some(QuicError))
```
## Evidence
Node-0 is the restarted original primary, node-1 is stopped, node-2 is the
new partition
primary.
View change correctly inherits an uncommitted op, node-0 adopts it but
repair stops one op
short and never widens:
```
view-change quorum merged; repairing up to the merged log before starting
the view
replica=2 view=2 op_head=21 commit_max=20
adopting view from StartView replica=0 old_view=2 new_view=2 op=21 commit=20
partition behind the group frontier; requesting repair from_op=1
commit_to_op=20 fetch_to_op=20 peer=2
partition journal repair complete shard=1 through_op=20
```
The primary retransmits the inherited op for the rest of the run; every
retransmit is
rejected:
```
(node-2) prepare timeout: retransmitting un-acked prepares replica=2 view=2
targets=1 first_op=21
(node-0) WARN iggy.partitions.diag: op=21 sequence=22 ... dropping
out-of-order prepare (gap)
```
The client's write reaches full quorum and still never commits:
```
PrepareAcked ... op=22 ack_from_replica=0 ack_count=2 quorum=2
quorum_reached=true
```
No `OperationCommitted` ever follows; at shutdown `commit_max` is still 20
on both survivors.
The client itself is a bystander: it reconnects to the correct new primary,
sends, then logs
nothing for about 24 seconds until its read timeout fires. It never dials
the stopped node.
## Root cause
The backup admission check is one comparison
(`core/partitions/src/iggy_partition.rs:3333-3352`):
```rust
if is_backup && header.op != current_op + 1 {
// "dropping out-of-order prepare (gap)"
return;
}
```
`current_op` comes from the sequencer, not the journal. The check's own
comment states its
limitation directly:
> `sequence` is what separates the two shapes this line covers: a forward
gap (op above the
> sequencer, the hole the repair driver closes) and a retransmit of an op
this replica
> already sequenced. Without it they read identically.
Both shapes fail the same comparison for the same reason, and the check
cannot tell them
apart. It does not fall back to the journal to disambiguate, and the comment
explains why
not: on this plane the journal is memory-only and reads empty right after
every restart, so
checking it here would misfire during ordinary post-restart catch-up.
This is a reasonable tradeoff on its own. It breaks because two things
compound:
1. **Repair narrows its fetch window under a commit lag, which a
just-restarted node
always has** (`core/shard/src/lib.rs:8324`): `let fetch_to_op = if
commit_lag {
commit_to_op } else { head };`. The adopted op's body is never requested.
2. **Once a later op is accepted, the sequencer advances past the hollow op,
permanently.** `Prepare(22)` passes the check (`22 == 21+1`), node-0 acks
it and its
sequencer moves to 22. Every later retransmission of op 21 now fails the
identical check
for the identical reason op 22 passed it. The window in which op 21 was
still
recoverable closes the instant anything else moves past it.
`ack_quorum_reached` (`core/consensus/src/plane_helpers.rs:399`) advances
the commit point
by walking forward from `commit_max` and breaking at the first entry without
quorum. Op 21
never gets a second ack, so the walk breaks there on every attempt, and op
22's own full
quorum is never reached. Replies are generated on commit, so the client is
never answered.
With 3 configured replicas and 2 live, quorum has no spare capacity: one
wedged backup
takes down the whole group.
## Fix
Close the window rather than trying to fix the check's disambiguation
problem. The repair
request already fetches a specific op range, not "everything": `from_op =
commit_min + 1`,
bounded by `fetch_to_op`. The fix computes `fetch_to_op` correctly
(`core/shard/src/lib.rs:8313-8324`), using `missing_suffix`, which the code
already
computed two lines above but never consulted in this branch:
```rust
let fetch_to_op = if commit_lag && !missing_suffix { commit_to_op } else {
head };
```
The cost of widening under a commit lag, refetching the committed prefix, is
already being
paid today regardless, since `from_op` is unconditionally `commit_min + 1`.
This only
extends the endpoint to cover the inherited suffix, bounded by pipeline
capacity. If the
body arrives before anything else can advance the sequencer past it, the gap
check never
has to make a distinction it cannot reliably make.
Fixing the gap check itself would be a harder, separate problem: the obvious
version,
checking the journal, is the exact thing its own comment rules out for the
post-restart
case. Any fix there would need to recognize the specific
adopted-but-unfilled range rather
than querying journal presence directly. Not needed here since the fix above
closes the
window the gap check would otherwise have to reason about.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]