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

   ## Bug description
   
   In a cluster, a Rust SDK producer that writes to several partitions of one 
topic
   stalls for about 4 seconds on nearly every send once those partitions' 
primaries
   are on different nodes. Each send moves the client's only connection to 
another
   node.
   
   Each partition is its own consensus group and elects its own primary. On a 
fresh
   cluster all partition primaries are on one node, so every send is admitted 
where
   the client is connected. After failovers (for example, nodes restarted one 
at a
   time) the primaries end up on different nodes. Polls handle this: 
`PollRouter`
   (`core/sdk/src/poll_routing.rs`) sends a poll to the partition's primary 
over a
   separate data connection and keeps the session where it is. Sends have no
   equivalent. `SendMessages` goes out on the one TCP session:
   
   1. The client is on node A. It sends to partition 0, whose primary is node B.
      Node A's replica is a backup, so it refuses the request with
      `TransientNotAccepted` (never admitted).
   2. `send_raw_vsr_attempt` replays the refusal on the same connection every 
50 ms
      until `TRANSIENT_FAILOVER_CHECK_INTERVAL` (2 s) expires.
   3. `send_raw_retaining_header` then takes `routing_lock` and `continue`s
      ("a concurrent refused request may have moved the shared client"), so a
      second 2 s replay window runs on the same node.
   4. Only then does it check the metadata leader, walk the roster
      (`RosterWalk`, `settle_on_endpoint`), disconnect, and reconnect and sign 
in
      on the next node. The send succeeds there.
   5. The next send goes to partition 1, whose primary is node A. Steps 1-4 
repeat
      in the other direction: 4 s of refusals on node B, then a redirect back to
      the metadata leader.
   
   A producer that spreads its sends over the partitions with
   `Partitioning::messages_key(..)` or explicit partition ids hits this on 
almost
   every send. Throughput is capped by the 4 s stall, not by the server, and the
   server sits nearly idle.
   
   **Expected:** a send reaches its partition's primary without the session 
moving
   on every send, the way polls already do. Once the primaries are known, a
   producer alternating between partitions should pay no refusal window and no
   reconnect.
   
   **Actual:** every send to a partition whose primary is not the connected node
   waits out two transient windows (about 4 s), then moves the session. With 
two or
   more primaries in play this happens on nearly every send.
   
   ## Affected area / component
   
   Rust SDK, Clustering / replication
   
   ## Deployment
   
   Compiled from source
   
   ## Versions
   
   - Rust SDK `iggy` 0.11.0 (released) against a 3-node cluster built from
     `f6dfbdbb3` with unrelated patches: observed (numbers below).
   - `master` @ `43fb44364`: failing integration test (PR linked below), 3 of 3
     runs.
   - Tag `iggy-0.11.0` (`71f29618b`): the same test fails the same way.
   
   The Go SDK (`foreign/go`) looks the same from reading the code, but I didn't
   run it. `SendMessages` resolves the partition client side and then goes 
through
   `c.do`. On `ErrTransientNotAccepted` that replays for `failoverCheckInterval`
   (2 s), redirects to the leader or walks the roster with
   `settleOnNextEndpoint`, and reconnects. There is no primary routing for 
sends.
   
   ## Hardware / environment
   
   - Observed: 3-node cluster on Kubernetes, 2 CPU / 8 GiB per node. 12 
producers,
     batches of 100 messages, 45k msg/s offered.
   - Reproduced: the integration harness (3 nodes on localhost) in a Linux 
x86_64
     container (4 CPUs), pinned toolchain from `rust-toolchain.toml`.
   
   ## Reproduction
   
   A failing test is on branch `test/sdk-send-partition-primary-routing` (PR
   linked below). It is `#[ignore]`d with this issue's number, so run it with
   `--ignored`:
   
   ```bash
   cargo build --bin iggy-server
   cargo test -p integration --test mod -- --ignored --exact \
     
cluster::partition_primary_routing::given_partitions_on_different_primaries_when_sending_round_robin_should_keep_the_session
   ```
   
   It uses the existing `seed_split_primaries` fixture in
   `core/integration/tests/cluster/partition_primary_routing.rs`. Partition 0 
stays
   on its primary while a metadata-only election moves the metadata leader. It 
then
   adds partition 1 with `create_partitions`. A partition created after the
   election is seeded from the metadata view, so its primary is the metadata 
leader.
   The test checks this precondition with `GET_POLL_ROUTING`: the two partitions
   report primaries on different nodes. It sends one warm-up message to each
   partition. Then it sends 4 messages alternating between partitions 0 and 1, 
and
   asserts that no send moves the session to another node.
   
   ## Logs
   
   The test's failure on master (partition, node before, node after, send time):
   
   ```text
   assertion `left == right` failed: a producer alternating between partition 0 
(primary node 0) and partition 1 (primary node 2) must keep its session; 4 of 4 
sends moved it to another node (partition, from node, to node, send time):
   [(0, 2, 0, 4.020694671s), (1, 0, 2, 4.018801233s), (0, 2, 0, 4.019829788s), 
(1, 0, 2, 4.018055753s)]
   ```
   
   The client log during that run, one send per pair of lines (node 2 at 
`:20016`
   is the metadata leader and partition 1's primary, node 0 at `:20000` is
   partition 0's primary):
   
   ```text
   20:54:02.365 iggy::tcp::tcp_client: The request keeps being refused on 
127.0.0.1:20016 while the roster names it the metadata leader; trying the next 
cluster node at 127.0.0.1:20000.
   20:54:06.390 iggy::tcp::tcp_client: Current node is not leader, redirecting 
to leader at: 127.0.0.1:20016
   20:54:10.408 iggy::tcp::tcp_client: The request keeps being refused on 
127.0.0.1:20016 while the roster names it the metadata leader; trying the next 
cluster node at 127.0.0.1:20000.
   20:54:14.428 iggy::tcp::tcp_client: Current node is not leader, redirecting 
to leader at: 127.0.0.1:20016
   ```
   
   Tag `iggy-0.11.0` gives the same pattern: `[(0, 2, 0, 4.068s), (1, 0, 2, 
4.071s), (0, 2, 0, 4.081s), (1, 0, 2, 4.078s)]`.
   
   Observed on the real cluster (SDK 0.11.0, 12 producers, batches of 100,
   45k msg/s offered). The server logs `rejecting client request on non-primary
   partition replica` for the refused sends:
   
   | topic | primaries | achieved | send p50 | send p99 | server CPU |
   | --- | --- | --- | --- | --- | --- |
   | 6 partitions, fresh cluster | all on one node | 46k msg/s | - | 21 ms | - |
   | 6 partitions, after three single-node restarts | spread over nodes | 
11,984 msg/s | 2.6 ms | 4,054 ms | 0.1-0.2 cores |
   | 1 partition, same load | one primary | 45,830 msg/s | - | 10 ms | - |
   
   The 4,054 ms p99 matches the two 2 s windows per misrouted send.
   
   ## Code references (`master` @ `43fb44364`)
   
   - `core/sdk/src/tcp/tcp_client.rs:83`: `TRANSIENT_FAILOVER_CHECK_INTERVAL = 
2s`.
   - `core/sdk/src/tcp/tcp_client.rs:1699-1704` (`send_raw_vsr_attempt`): 
replays
     `TransientNotAccepted` on the same connection until the transient deadline.
   - `core/sdk/src/tcp/tcp_client.rs:1436-1445` (`send_raw_retaining_header`): 
the
     first refusal only takes `routing_lock` and `continue`s, so a second full
     window runs on the same node before any routing decision. This is where the
     4 s instead of 2 s comes from.
   - `core/sdk/src/tcp/tcp_client.rs:1450-1525`: the leader check, then
     `RosterWalk` and `settle_on_endpoint` (`:1032`) move the session itself.
   - `core/sdk/src/poll_routing.rs`: polls and offset writes get the partition
     primary (`GET_POLL_ROUTING`) and use a per-node data connection. Sends 
have no
     such path.
   
   ## Impact
   
   - A producer writing several partitions loses most of its throughput after
     ordinary failovers (here 45k -> 12k msg/s), with 4 s tail latency and an 
idle
     server.
   - Partition primaries stay where the failovers left them, so the stalls
     continue for as long as the primaries stay split.
   - Every bounce is a disconnect, reconnect and sign-in, which adds session 
churn
     on the server.
   
   ## Suggested fix
   
   Route sends like polls: resolve the target partition's primary and send over 
a
   data connection to that node, keeping the session where it is. Cache the 
route
   and refresh it on `TransientNotAccepted`. The existing `PollRouter` 
connection
   pool, session binding and generation fencing look reusable. A send would need
   the dedup identity of the connection it is sent on, as replicated offset 
writes
   already have. A smaller step is to skip the second transient window. A 
refusal
   proves the request was never admitted, so after taking `routing_lock` the
   request could go straight to the routing decision instead of replaying for
   another 2 s. That halves the stall but does not stop the bouncing. The Go SDK
   would need the same change.
   
   ## Contribution
   
   - [ ] I'm willing to submit a pull request to fix this bug
         (the failing test is ready as a PR; the fix is open for discussion)
   


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