[Store] Drain in-flight RPCs and keep pools alive across client teardown - #3943
[Store] Drain in-flight RPCs and keep pools alive across client teardown#3943he-yufeng wants to merge 3 commits into
Conversation
6a81f5a to
dbc39a0
Compare
|
Follow-up on head dbc39a0: the CTest lane caught |
|
The CTest leg failed on |
|
Thanks for putting this together. The underlying issue is important, and the drain guard is a reasonable direction. I had a few questions about the remaining lifecycle and resource semantics before we can consider merging:
The fix is valuable, but I would prefer to resolve these points or document the intended trade-offs explicitly before giving approval. |
Teardown of a read-side client after consecutive RPC timeouts released the client pool while request coroutines were still suspended in it, and ylt only documents send_request as thread-safe, so the resumed coroutine touched freed state and segfaulted in asio's epoll_reactor (kvcache-ai#3909). Layers, all verified against current main: - A shared RpcDrainGuard (mooncake-common) stops admitting new calls once draining and makes the destructor wait for in-flight ones, with a 30s bound that logs loudly rather than silently proceeding. Wired into MasterClient, DummyClient and ClientRequester entry points. - RpcClientPool now hands out pools from a process-wide registry keyed by address instead of freeing them on teardown or address flaps. ylt pools own background reconnect coroutines that reference pool storage, so no amount of request draining makes pool destruction safe mid-retry; pools are one-per-master-address and deliberately process-lifetime. The first configuration for an address wins and a later mismatch is warned about loudly instead of silently ignored. - ClientRequester's offload pool collection follows the same lifetime rule: it is parked process-wide at teardown instead of being freed, since its pools host the same reconnect coroutines (kvcache-ai#3943 review). The 30s drain now covers response correctness only; a late resume never touches freed storage. Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
dbc39a0 to
f9844e4
Compare
|
@Icedcoco Good catches — all three points were real gaps, and the new head (will note the SHA after push) closes them: 1. Drain timeout / pool release. You were right, and it was worse than the drain-failure case: the ylt pool's background reconnect loop references pool storage whether or not a user call is in flight, so freeing 2. Registry config. Also fair. The registry now stores the first pool's salient knobs ( 3. Offload path coverage. Verified end to end: both New tests pin 1 and 2: |
Conflicts with the HA connection-policy split (kvcache-ai#3743) and the RpcProtocolConfig restructure (kvcache-ai#3976) resolved by keeping both sides: the drain guard now sits in invoke_rpc_with_client_pool so the foreground, HA control, and HA probe paths are all covered, and the batch guard is unchanged. The merge surfaced a real interaction: the shared-pool registry keyed pools by address alone, so an HA fast-fail probe on an address that already had a resilient foreground pool inherited the wrong retry budget (HaControlPolicyPreservesForegroundRetryPolicy fails: 4299ms where the probe must fail in under 750ms). The registry key now carries the behavioral knobs, so identical configurations keep sharing one pool while different policies on one address get their own. RegistryKeepsFirstConfigForSameAddress became RegistrySharesPoolOnlyForIdenticalConfig to pin the new contract. Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
|
@Icedcoco Merged current main into the branch (424c49e). The conflicts with your HA connection-policy split (#3743) and the RpcProtocolConfig restructure (#3976) are resolved by keeping both sides, with one placement change worth calling out: the drain guard moved from The merge also caught a real interaction between this PR and yours, and I want to flag it explicitly because it changes the registry behavior you reviewed. So the registry key now carries the behavioral knobs (connection count, retry count, reconnect wait, connect and request timeouts, socket flavor) instead of the bare address. Identical configurations still share one pool, which is the case the keep-alive exists for, and different policies get their own pools. The mismatch warning is gone because a mismatch can no longer alias two policies; Verified on the merged head in the dev container: |
|
Thanks for the follow-up and for addressing the earlier lifecycle and HA-policy concerns. I rechecked the current head and confirmed that the latest CI and relevant tests are green. I have two remaining questions about teardown behavior:
The registry and HA policy changes look substantially better now, and I am no longer treating CI as an issue. These questions are specifically about the remaining timeout and teardown semantics. |
…draining Two teardown-semantics fixes from review: - RpcDrainGuard counters move into a shared State held by each ScopedCall. A drain that times out lets the owner tear down anyway, and a ScopedCall still in flight then outlived the guard: its leave() touched freed counters. ASAN flags that as heap-use-after-free on the new regression test; with the shared state the late leave() is well defined. A second test pins counter consistency across a timed-out drain followed by a late leave. - ~DummyClient now runs tearDownAll() before drain_for(). unregister_shm() is itself an RPC and the ping thread is only joined inside tearDownAll(), so draining first rejected the unmap and spun the reconnection loop for the whole wait.
|
@Icedcoco Both were real bugs, thanks for pushing on the teardown semantics. Fixed in 0279884. 1. Guard lifetime after a timed-out drain You were right, the pool surviving was only half the problem. The counters now live in a shared I gave the regression test teeth by running it under ASAN:
2. DummyClient teardown RPCs after draining starts Not intentional, and worse than you described.
Verification: |
Description
Fixes #3909: tearing down a read-side client after consecutive RPC timeouts segfaults in asio's
epoll_reactor. Two lifetime violations, both verified by reading current main against ylt's documented contract (onlysend_requestis thread-safe; close/destruction must not race in-flight RPC):In-flight requests.
MasterClient/DummyClient/ClientRequesterdestructors just dropped the client pool whileco_await pool->send_request(...)coroutines could still be suspended. A sharedRpcDrainGuard(mooncake-common) stops admitting new calls once draining and makes the destructor wait for in-flight ones, bounded at 30s with a loud error rather than silently proceeding.Background reconnect coroutines. ylt's
client_poolreconnect loop (client_pool.hpp) holds references into pool storage and resumes across sleeps, so no amount of request draining makes pool destruction safe mid-retry.RpcClientPoolnow hands out pools from a process-wide registry keyed by address instead of freeing them on teardown or address flap. Pools are one-per-distinct-address per process and deliberately process-lifetime.Module
mooncake-store)mooncake-common)Type of Change
How Has This Been Tested?
Test commands:
Test results:
Linux container (Ubuntu 22.04): new
rpc_drain_guard_test(3 tests: drain waits for in-flight and refuses new calls, empty drain returns immediately, timeout reports false) passes.rpc_timeout_testgainsTeardownDrainsInFlightTimeoutRequests: 16 concurrentExistKeycalls in flight against a black-hole master at client teardown, 10 rounds, green with the fix.master_service_test69/69 andmaster_service_ha_test95/95 pass (one earlier run flaked once on an unrelated timing test under container load; two clean runs followed).Honest limitation: the reporter's crash is timing-dependent at 200-node scale, and my in-process harness did not crash pre-fix in this shape, so I am not claiming a local red-to-green crash repro. The fix is verified by construction against ylt's documented thread-safety contract plus the guard semantics tests above.
Checklist
./scripts/code_format.sh(clang-format plus pre-commit on every touched file; all hooks pass)AI Assistance Disclosure
Prepared with AI assistance (Kimi Code): the mechanism chain (drain + reconnect-storage) was traced on current main by reading, the fix and tests were drafted by the agent, and I reviewed the complete diff before submitting. All verification runs above were actually executed, and the no-deterministic-crash-repro limitation is stated as observed.
Update (2026-09-11, head f9844e4)
Reviewer questions closed:
ClientRequester's offload pool collection is no longer freed at teardown at all (detail::KeepClientPoolsAlive): the ylt reconnect loop references pool storage whether or not a user call is in flight, so freeing was the original UAF class on every teardown, not just on drain timeout. The 30s drain now covers response correctness only; on timeout, in-flight calls lose their answers, but no late resume touches freed storage. Verified against the offload RPC call sites (invoke_rpc<&RealClient::batch_get_offload_object>andrelease_offload_buffer), which both enter through the guardedinvoke_rpc.max_connection, connect/request timeouts) and warns loudly on a later config mismatch for the same address instead of silently keeping first-wins.RegistryKeepsFirstConfigForSameAddress,KeepClientPoolsAliveRetainsCollection. rpc_client_io_context_test 5/5, rpc_drain_guard_test 3/3, rpc_timeout_test 6/6, all in the Linux container.