almostintuitive commented on PR #4433:
URL: https://github.com/apache/iggy/pull/4433#issuecomment-6044243711
Thanks for this fix, @numinnex. Before it lands, we want to report a
regression we hit while testing it.
**Summary.** With this PR applied on current master (`43fb44364` + PR head
`4b3e5684b`), a 3-node cluster loses **every acknowledged message** in a
`--durability persisted` topic when each node is SIGKILLed and restarted in
turn while writes are in flight. Master alone passes the same test. In the
logs, the first node restarted under load gets stuck: its partition is fenced
from `commit_min=0`, and repair never gets past the first `repair_chunk_max`
(128) ops. In a narrowed 1-partition repro, raising `cluster.repair_chunk_max`
to 1024 makes the failing configuration pass (we have not tried this on the
original 6-partition configuration). That points at an interaction between the
new view fence and chunked repair (analysis below; not proven).
### Setup
- Three real server processes in Docker. Each node has 2 CPUs and 4 GiB, a
Docker volume for its data dir, `seccomp=unconfined` for io_uring, 2 shards, a
1 GiB memory pool, cluster auth on, and otherwise default config.
- One stream and one topic, `topic create <s> <t> <N> none unlimited
--durability persisted`, with no other topic options.
- Six Rust-SDK producers send batches of 20 messages (~256 B payloads,
partitioned by key). A failed send is retried with the same messages until it
is acknowledged. About 20% of sends are resends of earlier acknowledged
messages, some from fresh sessions.
- Fault schedule, measured from the start of load: kill node 0 at 15 s and
start it at 30 s, kill node 1 at 45 s and start it at 60 s, kill node 2 at 75 s
and start it at 90 s. Kills are `docker kill -s KILL`, and each node restarts
from its volume. Load lasts 90 s.
- Once all nodes are up, a verifier reads each partition from offset 0 up to
the `current_offset` reported by GetTopic. Empty polls are retried for up to
120 s. Every acknowledged message must be present.
### Results: original configuration (6 partitions, ~2,000 msg/s)
| Server build | Result |
|---|---|
| master `43fb44364` | PASS. 0 of 154,180 acknowledged messages lost
(173,636 in the log, including retry duplicates) |
| **master + this PR (`4b3e5684b`)** | **FAIL ×2. 91,680 and 91,320
acknowledged; 0 messages in the log** |
| `f6dfbdbb3` (parent of #4387) | PASS. 0 of 153,720 lost |
| `f6dfbdbb3` + #4429 (backport) | PASS ×2. 0 of 150,900 and 0 of 152,780
lost |
| `f6dfbdbb3` + #4429 + this PR (backport with small adaptations, e.g.
`FatalCommit.operation` is not an `Option` there, so `Operation::Reserved`) |
FAIL. All 95,280 lost, same symptom |
In the failing runs every send was acknowledged in the end. Send latency
peaked at ~31 s, and a few dozen transient errors were retried. After the run,
the GetTopic answer from each of the three nodes is `Topic size 0 B`, `Topic
message count 0`, and every partition polls empty. The same schedule with no
load during the kills did not lose data.
### Narrowed repro (1 partition, same kill schedule unless noted)
| Build | Rate | Result |
|---|---|---|
| master | 500 msg/s | PASS. 0 of 40,480 lost |
| master + PR | 500 msg/s | FAIL. 23,120 of 23,120 lost |
| master | 150 msg/s | PASS ×2. 0 of 13,460 and 0 of 13,560 lost |
| master + PR | 150 msg/s | FAIL ×2. 7,860 of 7,860 and 6,840 of 6,840 lost |
| master + PR, `IGGY_CLUSTER_REPAIR_CHUNK_MAX=1024` | 150 msg/s | PASS ×2. 0
of 13,460 and 0 of 12,700 lost |
| master + PR, `IGGY_CLUSTER_REPAIR_CHUNK_MAX=1024` | 500 msg/s | PASS. 0 of
37,320 lost (but one node showed the stall below; see analysis) |
| master + PR, **one** kill/restart only (node 0) | 150 msg/s | PASS. 0 of
9,800 lost, but node 0 never caught up (below) |
One FAIL run at 150 msg/s queried GetTopic on every node between steps.
These queries shifted the later steps by up to ~45 s. After node 1 was killed,
the message count stopped at 7,495 on every node that answered; in the same
window of the master control run, the count went from 9,546 to 15,070. The
count stayed at 7,495 after node 1 restarted. Within 5 s of killing node 2, the
two remaining nodes (both restarted earlier) answered **0**. Max send latency
in that run was 125.7 s (master: 15.1 s).
### What the logs show
The narrowed single-kill run (PR, 1 partition, 150 msg/s) shows the stall on
its own. After restarting, node 0 fences its whole history and asks a peer for
ops 1..247. The peer serves through op 128. Node 0 then asks for 1..247 again,
about once per second, for the next ~100 s, until the logs were collected:
```
node-0 18:16:48.668 INFO partitions::iggy_partition: holding partition
entries journaled before this view until its log proves them namespace_raw=0
view=1 commit_min=0 through=246 anchored=true
node-0 18:16:48.669 INFO shard: partition behind the group frontier;
requesting repair namespace_raw=0 from_op=1 commit_to_op=247 fetch_to_op=247
peer=1
node-1 18:16:48.671 INFO shard: served partition repair range
namespace_raw=0 target=0 from_op=1 to_op=247 served_through=128
node-0 18:16:49.780 INFO shard: partition repair stalled; re-requesting
remaining window namespace_raw=0 from_op=1 to_op=247 peer=1
node-1 18:16:49.784 INFO shard: served partition repair range
namespace_raw=0 target=0 from_op=1 to_op=247 served_through=128
... (97 re-requests in total, last one at 18:18:26, still from_op=1
to_op=247)
```
The original 6-partition failing runs show the same pattern on every
partition of the first restarted node, with ~90 re-requests from `from_op=1`.
After that come `partition repair stalled past its retry budget; re-arming from
another replica ... from_op=1`, `partition journal repair completed or was
superseded ... commit_min=0 consensus_view=2` once node 1 is killed, and `no
header at this replica's commit point; the DVC reports it blank and cannot nack
it, so the view change stalls unless a peer supplies it op=466 commit=466`. The
stuck node also fails to apply the live stream: `error="Cannot append message"
failed to apply replicated partition operation`, followed by `dropping
out-of-order prepare` for every later op.
For comparison, on master a restarted node asks for `from_op=65` and walks
the window one chunk at a time. For example, its `commit_min` goes 731 → 1062 →
1140, and repair completes within a few seconds. Master never logs a repair
request from `from_op=1` in these runs.
A correction to something we first suspected: the segment recovery warning
below appears on **every** build after each SIGKILL, master included, so it is
not specific to this PR:
```
WARN partitions::segment_recovery: truncating a segment file to its
recovered bounds; discarding torn tail bytes
path="local_data/streams/0/topics/0/partitions/3/00000000000000000000.index"
current_size=480 target_size=0
```
On `f6dfbdbb3`-based builds it comes with `segment log holds bytes but no
whole batch decodes anywhere in it (torn write); recovering the segment as
empty and fencing its files aside ... messages_size=4014015`. So in this setup,
on every build, a restarted replica rebuilds its partition history from peers
through repair. This may be expected, but it explains why a repair that never
finishes ends up losing data.
### Analysis (our reading of the code; please correct us)
1. On the first `StartView` adoption after boot, `recovered_unproven`
(`persistence.head() > 0`) sets `fence_below`. `fence_unproven_view_window`
then fences `(commit_min, anchor.op - 1]`, which is `(0, 246]` above.
2. While `view_fenced()` holds, `commit_journal` returns early after
`prove_view_window()` and `collect_committable_from_journal` returns nothing.
`commit_min` therefore cannot move until every op up to `through` is present
and chains to the anchor.
3. Partition repair moves to the next chunk only when `commit_min` moves
(`partition_repair_next_chunk` needs `commit_min > before`). The stall retry
re-requests from `commit_min + 1`, and the server serves at most
`repair_chunk_max` ops per request.
4. So when the fenced window is wider than `repair_chunk_max`, the requester
receives ops 1..128. The window still has a hole at the top, so `commit_min`
stays at 0, and the same chunk is requested again. Nothing ever fetches
129..`through`. The runs match this in both directions. With chunk 128, the
first restarted node stalled this way on every partition in all 5 failing runs
on master + PR, and in the single-kill run (windows of 156 to 644 ops). With
chunk 1024, restarts whose window was smaller completed in a single chunk:
252/543/810 and 246/561/761 ops in the two 150 msg/s runs, and 551 in the 500
msg/s run. The only one that stalled was a node whose window was 1,492 ops: 15
re-requests of 1..1493, `served_through=1024`. That run still passed,
presumably because the other restarted node had caught up.
5. Our guess at how a stall turns into total loss (not traced in detail):
the stuck replica holds no history and cannot append live prepares. While
fenced, `fenced_serve_ceiling` caps its repair serving at its own `commit_min`
(0). The later restarts in the 128-chunk runs looked wrong in other ways too.
In one run, the second restarted node fenced `(0, 413]` and sent no partition
repair request at all. In another, it fenced `(0, 1365]` and, as primary-elect,
repaired only ops 1420..1425 before logging `partition journal repair
complete`. Once the last never-restarted replica is killed, the remaining
replicas appear to form a view without the history.
### What we have not established
- The exact path from "stuck replica" to "all three nodes report 0 messages"
(step 5 above).
- Why the restarted replica fences from `commit_min=0` with the PR, while on
master it resumes at op 65.
- Why recovered segment bounds are 0 after every SIGKILL on all builds. This
may be by design, and we have not looked into it.
- Whether the idle case passes only because no restart needed more than one
chunk. We did not check window sizes in that run.
- We have not written a simulator or integration test. Something like
"restart a backup under live traffic while the cluster is more than
`repair_chunk_max` ops ahead of its recovered WAL, then assert it reaches
`commit_max`" looks like the shape of it.
<details>
<summary>Cluster setup used (plain Docker)</summary>
```bash
NET=repro; SUB=172.50.0; docker network create --subnet $SUB.0/24 $NET
for i in 0 1 2; do
roster=()
for n in 0 1 2; do
roster+=(-e IGGY_CLUSTER_NODES_${n}_NAME=node-$n -e
IGGY_CLUSTER_NODES_${n}_IP=$SUB.1$n
-e IGGY_CLUSTER_NODES_${n}_REPLICA_ID=$n -e
IGGY_CLUSTER_NODES_${n}_PORTS_TCP=8090
-e IGGY_CLUSTER_NODES_${n}_PORTS_QUIC=8080 -e
IGGY_CLUSTER_NODES_${n}_PORTS_HTTP=3000
-e IGGY_CLUSTER_NODES_${n}_PORTS_WEBSOCKET=8092 -e
IGGY_CLUSTER_NODES_${n}_PORTS_TCP_REPLICA=9090)
done
docker run -d --name node-$i --hostname node-$i --network $NET --ip
$SUB.1$i \
--cpus 2 --memory 4g --security-opt seccomp=unconfined -v
node-$i:/app/local_data \
-e IGGY_ROOT_USERNAME=iggy -e IGGY_ROOT_PASSWORD=iggy \
-e IGGY_TCP_ADDRESS=$SUB.1$i:8090 -e IGGY_HTTP_ADDRESS=$SUB.1$i:3000 \
-e IGGY_QUIC_ENABLED=false -e IGGY_WEBSOCKET_ENABLED=false \
-e IGGY_SHARDING_CPU_ALLOCATION=2 -e IGGY_SHARDING_PIN_CORES=false -e
IGGY_MEMORY_POOL_SIZE=1GiB \
-e IGGY_CLUSTER_ENABLED=true -e IGGY_CLUSTER_NAME=repro \
-e IGGY_CLUSTER_AUTH_ENABLED=true -e
IGGY_CLUSTER_AUTH_SHARED_SECRET=<48-char secret> \
"${roster[@]}" <server image built from 4b3e5684b> --replica-id $i
done
iggy --transport tcp --tcp-server-address $SUB.10:8090 -u iggy -p iggy
stream create s
iggy --transport tcp --tcp-server-address $SUB.10:8090 -u iggy -p iggy topic
create s t 1 none unlimited --durability persisted
# Start a producer that retries every failed batch until acked (~150 msg/s,
batches of 20), then:
sleep 15; docker kill -s KILL node-0; sleep 15; docker start node-0
docker logs -f node-0 2>&1 | grep -E 'holding partition
entries|re-requesting remaining window'
# For the data loss, continue: kill node-1 at 45 s, start it at 60 s; kill
node-2 at 75 s, start it at 90 s.
```
</details>
We can share the full node logs and verifier output from any of these runs,
rerun with a different `RUST_LOG`, or test a candidate fix in the same setup.
Just let us know what would help.
--
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]