almostintuitive opened a new issue, #4438:
URL: https://github.com/apache/iggy/issues/4438

   ### Bug description
   
   After rolling crash-restarts under produce load, a restarted partition 
replica can stay behind its group for good. It keeps serving the partition, so 
readers that poll it stop at its stale prefix while the other two replicas hold 
the full log.
   
   On restart, the replica's durable state ends below the repair window that 
its peers still retain. It refuses the commit floor and asks for a state 
transfer:
   
   ```
   refusing commit floor: repaired window does not connect to recovered durable 
state (needs state transfer)
   ```
   
   The state transfer never completes, and the replica does not escalate or 
step aside. It keeps answering polls up to its own `commit_min`.
   
   No acknowledged data is lost with `persisted` durability: restarting only 
the lagging node made every acknowledged event readable again. Before that 
restart, about half of the acknowledged events could not be read through the 
stuck replica.
   
   **Expected:** the restarted replica installs a state transfer (or repairs) 
and catches up with its group.
   **Actual:** it stays at its stale prefix until an operator restarts it.
   
   ### Affected area / component
   
   Clustering / replication (partition plane: journal repair and state transfer)
   
   ### Deployment
   
   Compiled from source, 3 `iggy-server` processes in Docker containers on one 
host.
   
   ### Versions
   
   `master` @ `f6dfbdbb3`. The code paths described below are unchanged on 
current `master` (`43fb44364`); see the failing tests in the linked PR.
   
   ### Hardware / environment
   
   Linux x86_64, local disk. Each node is a container limited to 2 CPU and 4 
GiB of memory.
   
   ### Reproduction
   
   1. Start a 3-node cluster.
   2. Create one stream and one topic with 1 partition and `--durability 
persisted`.
   3. Produce at a combined 30,000 msg/s for 360 s: 12 producers, batches of 
100.
   4. During the load, SIGKILL the nodes one at a time, 80 s apart. Keep each 
node down for 40 s, then restart it from its data directory. That is: kill node 
0 at 80 s, start it at 120 s, kill node 1 at 200 s, start it at 240 s, kill 
node 2 at 320 s, start it at 360 s.
   5. After the load stops, read the partition from offset 0 and compare 
against every acknowledged message.
   
   Results:
   
   - Plain `f6dfbdbb3`: 1 of 2 runs failed. 2,283,600 of 4,570,200 acknowledged 
events were unreadable. Two nodes held an identical 1.62 GB on disk, the 
lagging node 0.80 GB. After a restart of only the lagging node, 0 of 4,570,200 
events were missing.
   - A build with additional, unrelated changes: 4 of 4 runs failed with the 
same signature.
   
   The failure is timing dependent. It needs a restarted replica whose 
recovered segments end below the oldest op that its peers still retain in their 
repair ring, which is likely at this write rate.
   
   ### Logs
   
   On the lagging node, repeated for minutes (messages verbatim, structured 
fields abbreviated):
   
   ```
   refusing commit floor: repaired window does not connect to recovered durable 
state (needs state transfer)
   partition state transfer install failed; falling back to journal repair  
error="transfer frontier 34274 is below the local commit frontier 36787"
   refusing a partition transfer offer from a replica behind this one  
serving_view=20 local_view=58
   ```
   
   In one run the third line appeared 117 times, while the serving primary at 
view 20 already had a higher `commit_max` than the lagging replica. In another 
run, after the first two causes below were removed, the first line repeated 239 
times over 3.5 minutes with no state transfer request at all.
   
   ### Analysis
   
   This section is my analysis. I found three separate livelocks in the 
state-transfer path. Each one alone keeps the replica behind. The linked PR 
adds one failing test per livelock, marked `#[ignore = "#<this issue>: ..."]`.
   
   1. **The install refuses an offer once `commit_max` moves past it during the 
pull** (`core/partitions/src/state_transfer.rs`, `install_state_transfer`). The 
serving primary builds the offer at its `commit_min`. The pull takes 10 to 20 s 
under load, because the active segment alone is hundreds of MiB. Meanwhile, the 
primary's commit heartbeats raise the receiver's `commit_max`. The install 
refuses any `commit_op < commit_max`, so every round fails with `transfer 
frontier X is below the local commit frontier Y`, and the next round is just as 
far behind.
      The guard exists so that the rewind to `commit_op` never erases a 
journaled op that this replica knows is committed. Those ops are `(commit_op, 
min(commit_max, sequencer)]`. A gap-stopped receiver has its sequencer below 
the offer, so the install erases nothing. The committed tail above the offer is 
what journal repair fetches after the install, as the install's doc comment 
already describes.
      Suggested direction: refuse only when `commit_op < min(commit_max, 
sequencer)`. A primary's sequencer is at least its `commit_max`, so primaries 
keep the current behavior.
   
   2. **The descriptor gate compares the offer with a view that the replica 
raised alone** (`core/shard/src/lib.rs`, `on_partition_state_transfer_target`). 
A replica that needs state transfer cannot finish a view change. With 
`persisted` durability it cannot vote in one either: its WAL cannot certify a 
holed log, so every view-scoped send is withheld. Its elections time out with 
nobody hearing them, and its `view` keeps rising (58 against the primary's 20 
in the run above). The gate refuses an offer with `serving_view < local view`, 
so it refuses every offer from the group's real primary, even when that offer 
holds more committed state.
      Suggested direction: compare the serving view with the local `log_view` 
(the last view whose log this replica adopted). That still refuses a phantom 
view-0 primary, an offer from before the adopted view, and any offer below the 
local `commit_max`.
   
   3. **A refused commit floor never becomes definitive when the repair window 
is longer than one chunk** (`core/partitions/src/iggy_partition.rs`, 
`complete_repair`). A floor refusal converts to a state transfer only when the 
whole window `(floor, to_op]` is resident, because a later frame could still 
lower `first_batch_offset` into connection. But the next 128-op repair chunk is 
requested only when the commit walk advances, and a refused floor never lets it 
advance. So every stall retry fetches the same first chunk, and the window 
never completes. Under load the serving peer eventually evicts the whole 
window, which empties it and ends the wait. After load stops, the replica 
refuses the floor forever and never asks for a transfer.
      Suggested direction: record the op whose batch set `first_batch_offset`. 
Offsets rise with ops, so once every op in `(floor, that op)` is resident, no 
later frame can lower the first batch offset, and the refusal is final.
   
   None of these changes serves or acknowledges anything new. Directions 1 and 
2 decide which committed state a receiver may install. Direction 3 decides when 
to start a transfer.
   
   With all three changes applied on top of `f6dfbdbb3` plus unrelated changes, 
the same scenario passed 3 of 3 runs with 0 lost events, and all three replicas 
ended with identical segment bytes. In each run the lagging replica needed a 
state transfer 2 to 26 times and installed it within 10 to 20 s while the load 
continued.
   
   A separate question: should a replica that knows it needs state transfer 
keep serving reads for the partition? Its data is committed, so it is stale but 
never wrong. But a consumer that polls only that replica sees the partition 
stop.
   
   ### Contribution
   
   - [x] I'm willing to submit a pull request to fix this bug
   
   I have the fix for all three, with tests, and can open it once the direction 
is agreed here.
   


-- 
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]

Reply via email to