fix: end transfer-lock leaks — QPN collision ownership, RAII release guard, session worker leak, fetch chunk clamp#399
Conversation
A transfer session is created on the holder the moment QueryBlocksForTransfer succeeds, but the requester only sent ReleaseTransferLock on the coded success/error paths. If the fetch task died between the two — a panic unwinding through the tokio task, or the future being dropped mid-await — the session was never released and the holder pinned the blocks until the 120s GC expired them. Production evidence (4-node cluster, v0.23.2): holder-side RPC counters showed query_blocks_for_transfer ok=2235 vs release_transfer_lock ok=1961; the deficit matches the cumulative transfer-lock timeouts (266), while the requester logged zero fetch errors — the releases were never sent, not failing. Replace the fire-and-forget calls with a TransferLockGuard constructed immediately after the query succeeds. Coded paths release explicitly; Drop covers panic/cancellation, logs a WARN with session/remote/req_id so any remaining abort path is visible, and spawns the release on the runtime handle captured at construction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…isions Root cause of the kongtian incident: PerNicState kept sessions and remote_memory in HashMaps keyed by the remote first QPN. QPNs are only unique within one remote HCA, so two peers can collide (near-certain when peers boot simultaneously from the same image). On collision the second handshake silently overwrote the first peer's entries — its transfers then failed with "remote memory not found in handshake snapshot" — and invalidating either peer destroyed the other's session vec while its addr_connections entry survived. Every later transfer to that peer hit the "session vec must exist" expect (panic on v0.23.2, error since #384), and because the connection still looked Existing it never re-handshook: a permanent wedge that leaked one transfer lock per fetch. Make AddrConnection own its per-NIC state (ConnNic { sessions, remote_memory, rr_counter }) and delete the QPN-keyed maps, so the collision class is unrepresentable and invalidation is one atomic remove. Handshake snapshots are now validated before mutating any state, so a mid-loop failure no longer orphans map entries. Also fix the mirror wedge on the accept side: rdma_accept_handshake now aborts the handshake when complete_handshake fails, instead of leaving the client stuck in "connecting" forever. BREAKING METRIC CHANGE: pegaflow_rdma_qps previously reported peers x nics (map entries); it now reports actual QPs, i.e. scaled up by qps_per_peer, matching its description and the cpu_bench assertion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#387 capped fetch staging chunks at 256 MiB to bound LRU-reclaim amplification, but also made every fetch allocate at least that much: a small fetch on a pool smaller than the cap fails outright with "failed to allocate fetch chunk". Found by the p2p_rdma IT (16 MiB pool), which #387 broke — confirmed by A/B against master on an H200 node with 400G IB. Size each chunk as min(remaining bytes on that NUMA, 256 MiB) instead: small fetches allocate exactly what they need again, large fetches keep the amplification cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The worker thread captured Arc<RcSession> while the session owned the only cmd_tx Sender, so recv() could never disconnect: every invalidated or aborted connection leaked its worker thread, QP, and CQ forever. Hold a Weak instead — the upgraded Arc pins the session only for the duration of a batch, and dropping the last external ref drops cmd_tx, unblocks recv(), and lets the worker exit and release the QP. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rdma_fetch.rs had grown past the 1k-line limit; the guard and its loopback release-counting test are a self-contained unit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Workers torn down before their first command (e.g. aborted handshakes) logged local_qpn=0; pass the QPN at spawn instead of capturing it lazily, and restore the started log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
I found a small race in the new This does not leave A permanently successful: its receiver will eventually observe Could the |
…e-execution A command can be enqueued right before invalidate_connection drops the last session Arc; the worker then dequeues it, fails the Weak upgrade, and exited dropping the command — the caller saw a generic channel-closed error. Reply with an explicit "session invalidated before batch execution" error for the dequeued command and everything still queued, then exit. Suggested by review on #399. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Good catch — fixed in cd93e59. The Re-ran the |
Release v0.23.4. Highlights since 0.23.3: - #399 — transfer-lock leak fixes: cross-peer QPN collision eliminated via per-connection session ownership, requester-side RAII release guard, session worker thread/QP leak fix, RDMA fetch chunk right-sizing. - Note: #399 changes the `pegaflow_rdma_qps` gauge semantics — it now reports actual QP count (scaled by `qps_per_peer`); expect a step in dashboards. - #395 — PD first-token router forwarding + P-side partial tail-block save. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Incident
On a 4-node H200 production cluster (v0.23.2), two holder nodes logged a steady stream of
Transfer lock expiredwarnings (~270 sessions per holder over 11h, still growing), each session pinning up to ~1,400 blocks for the full 120s lock timeout before GC reclaimed them. Holder-side RPC counters showed the smoking gun:query_blocks_for_transfer ok=2235vsrelease_transfer_lock ok=1961— the release deficit matched the cumulative timeout count almost exactly, while the requesters logged zero fetch errors. The releases were never sent, not failing.Root cause chain (all confirmed by logs / stderr)
PerNicStatekeptsessionsandremote_memoryinHashMaps keyed by the remote first QPN. QPNs are only unique within one remote HCA — peers booted from the same image at the same time allocate near-identical QPNs, so two peers' keys collide. The second peer's handshake silently overwrote the first peer's entries, so transfers to the first peer ran against the wrong remote-memory snapshot →remote memory not found in handshake snapshot.addr_connectionsentry survived. Every later fetch toward peer 2 hit.expect("session vec must exist for established connection")— a panic on v0.23.2 — and since the connection still lookedExisting, it never re-handshook.ReleaseTransferLockwas never sent and the holder pinned the blocks until the 120s GC. Requester stderr showed 340 / 311 panic groups, each aligned ~122s before a holder-side lock timeout.Fixes
a9aa74e(root cause) —AddrConnectionnow owns its per-NIC state (ConnNic { sessions, remote_memory, rr_counter }). The QPN-keyed global maps are deleted, so the collision class is unrepresentable;invalidate_connectionis one atomic remove, and a half-removed connection can no longer be observed. Also fixes the mirror wedge on the accept side:rdma_accept_handshakenow aborts the handshake whencomplete_handshakefails instead of leaving the client stuck inconnectingforever.7788555(defense in depth) — requester-side RAIITransferLockGuard: the session is released on every exit — completion, handled error, panic unwind, or the future being dropped. The drop path logs a WARN withsession/remote/req_idso any remaining abort path is visible in production. Covered by a loopback tonic test asserting exactly-once release across all three exits.3cea9bf— session worker threads capturedArc<RcSession>while the session owned the only commandSender, sorecv()could never disconnect: every invalidated/aborted connection leaked its worker thread, QP, and CQ forever. The worker now holds aWeakand upgrades per batch.562d521— fix(core): reduce RDMA prefetch reclaim amplification #387 capped fetch staging chunks at 256 MiB but also made every fetch allocate at least that much, which fails outright on pools smaller than the cap. Found by thep2p_rdmaIT (16 MiB pool), which fix(core): reduce RDMA prefetch reclaim amplification #387 had broken — confirmed by A/B against master on real hardware. Chunks are now sizedmin(remaining bytes on that NUMA, 256 MiB), keeping the amplification cap for large fetches.bb480ef/9fa2830— splitTransferLockGuardinto its own module (file back under the 1k-line limit); log real QPNs across the worker lifecycle.Breaking metric change
pegaflow_rdma_qpspreviously reported map entries (peers × NICs); it now reports actual QP count, i.e. scaled up byqps_per_peer, matching its own description ("Active RC queue pairs") and thecpu_benchassertion. Expect a step in dashboards.Validation
p2p_rdmaIT on an H200 node with 400G IB (PEGAFLOW_IB_DEVICE=mlx5_gpu*): fails on master (chunk allocation error), passes on this branch (4/4 blocks RDMA-fetched, data verified, lock released, all session workers exit cleanly on teardown — previously they leaked).cargo clippy --workspace --all-targetsclean; 125 pegaflow-core + 17 pegaflow-transfer tests pass; pre-commit hooks (incl.cargo test --release) passed on every commit.Follow-ups (pre-existing, out of scope, issues to be filed)
rdma_accept_handshakehas anunreachable!reachable under simultaneous mutual handshake (invalidate →get_or_preparewindow); should return an error instead.🤖 Generated with Claude Code