Repository navigation
[TransferEngine] Recover redis metadata connection after disconnects - #4042
Conversation
|
Note: #4015 covers the same bug and was opened first. I've left a comment there comparing the two implementations — #4042 additionally adds connect/command timeouts, TCP keepalive, and hermetic tests that actually drop a connection (also clean under ASan/UBSan), and has been validated for 5 days in a PD-disaggregated production deployment. Deferring to maintainers on how to combine the two. |
|
CI note: one real failure, not two — Failing job: This looks like a mooncake-pg control-plane race on destroy + re-init, not something this PR can reach:
The sibling |
he-yufeng
left a comment
There was a problem hiding this comment.
Read the full diff and built + ran the new test suite locally on this branch (mc-dev container): 7/7 pass, and the log lines show the exact failure mode being exercised (server drops connection mid-run, reconnect, retry succeeds; down at construction recovers; auth + SELECT replayed on the fresh socket; stays-down fails clean).
The shape is the right one for this bug class. hiredis latching every later command onto a dead context is exactly why a single idle-timeout used to be a permanent outage, and rebuilding on demand with one retry over idempotent ops is the minimal correct answer. The things I specifically checked:
- The retry only fires on a null reply (connection-level), never on REDIS_REPLY_ERROR, so a server-side rejection can't be silently re-executed.
- AUTH and SELECT are replayed on every reconnect, not just at construction, which is required for a managed Redis behind a balancer.
- SO_KEEPALIVE failure is a warning, not fatal; the connection still works, it just can't defend against middlebox idle drops by itself. Reasonable degradation.
- The constructor no longer hard-fails when Redis is briefly down at startup; every operation re-attempts. That changes the startup contract in a good way and the
RecoversWhenRedisIsDownAtConstructiontest pins it. - The timeout constants match the store-side coordinator client you reference (redis_connection.cpp:20-21), confirmed against main.
One non-blocking observation: the get() nil reply now logs at WARNING where it used to be ERROR, and the comment explains why (absent key is an expected lookup miss). Worth a line in the PR description since it changes log semantics for existing users.
Nice work, and the in-process RESP fake is the right call for this test.
|
Thanks for the thorough read, and for building and running the suite locally. Added the log-semantics note to the description as suggested: Two things still open that I cannot do from a fork: the one red job is the unrelated mooncake-pg |
|
I agree that this is the more complete fix for #4003. Before merging, please:
The existing behavior where SET/DEL treat Once the branch is updated and the Redis regression tests run in CI, I’m comfortable approving this PR and treating #4015 as superseded. |
5726243 to
d451255
Compare
|
Thanks — all four points are addressed. Pushed as three commits so the new work is 1. Updated to latest main. Rebased onto 2. The PG job. The force-push started a fresh run, so 3. 4. SET/DEL and One change beyond your list, which the rebase forced — please look at #3072 added So Local runs on the rebased branch: 9/9 in 167 ms, and 9/9 under Two notes on accuracy while I was there: the 5-day production run covers the reconnect, @he-yufeng your approval predates these two commits — the new code is all in |
d451255 to
8097e88
Compare
|
All checks are green on 8097e88. Rebased again onto the latest main (6cc1a10), which also re-triggered CI. The Redis The intermediate run had one red test, Item 4 from your list is already handled: I filed #4099 and @he-yufeng has fixed it in One practical note on merge order, since #4107 and this PR both touch Nothing outstanding on my side otherwise — happy to make further changes if you spot |
8097e88 to
2b9f8f6
Compare
hiredis latches I/O errors into the redisContext, so a single disconnect made every later metadata operation fail for the rest of the process lifetime. In production a managed Redis behind a TCP load balancer reclaimed the idle connection after ~4000s and the node never recovered. Rebuild the connection and retry once on connection-level failure, move AUTH/SELECT into the connect path so they are replayed on reconnect, and add connect/command timeouts plus TCP keepalive. Add unit tests driven by an in-process RESP fake that can drop connections on demand. Fixes kvcache-ai#4003
kvcache-ai#3072 added RedisStoragePlugin::getWithStatus() so syncSegmentCache() can tell an absent key from a backend outage. That override issued its own redisCommand() with no reconnect, so kvcache-ai#4003 remained reproducible through it: once hiredis latched an I/O error into the context, every later getWithStatus() returned kUnavailable for the rest of the process lifetime, and syncSegmentCache() calls it periodically. Route it through the same execLocked() retry path and let get() delegate to it, which also removes the duplicated GET handling. A null reply after execLocked() means the backend is unreachable, so it maps to kUnavailable and never to kNotFound, preserving the invariant kvcache-ai#3072 added. Add GetWithStatusRecoversAfterServerDropsConnection and GetWithStatusDistinguishesAbsentKeyFromOutage, which pins both directions: an absent key stays kNotFound while the server is up, and a stopped server reports kUnavailable rather than kNotFound.
2b9f8f6 to
1721a10
Compare
|
@stmatengss @ykwd @Aionw @Ann-1024 @luketong777 This is blocked only on code-owner review. All of @alogfans's merge conditions are met and CI is green — could one of you approve when you have a moment? Thanks. |
|
@alogfans This is now blocked solely on Two ways forward, whichever you prefer:
Happy to do the split if that's easier — just let me know. |
1721a10 to
e1e12b1
Compare
|
Went with option 2 to unblock this. I moved the one-line This PR now touches only @alogfans once CI is green, could you merge this (or enable auto-merge)? I don't have merge rights on the repo. The CI-enable in #4274 can go through the |
The job that runs ctest configured the tree without -DUSE_REDIS=ON, so transfer_metadata_redis_plugin_test was never built there, and the job that does enable USE_REDIS only builds without running ctest. The redis regression tests therefore had compile coverage but were never executed. dependencies.sh already installs libhiredis-dev on this runner and the top-level CMakeLists calls enable_testing(), so enabling the flag is enough for ctest to pick the target up. The tests need no redis server: they drive an in-process RESP fake.
|
@Aionw done — folded the Since it touches |
|
Gentle bump — green and idle since Sep 23. This is now blocked solely on a I don't have merge rights, so once that approval lands, could a maintainer merge or enable auto-merge? Thanks! |
|
@Aionw @alogfans — gentle follow-up: this has been idle for six weeks (last change Sep 23) and four since the bump above. It's still green and merges cleanly into current That line is still needed. Current To stop holding up the reconnect fix: if no |
Description
Fixes #4003.
RedisStoragePlugininmooncake-transfer-engine/src/transfer_metadata_plugin.cppheld a single long-lived
redisContextand never rebuilt it. hiredis latches anyI/O error into the context and rejects every subsequent command, so one
disconnect permanently disabled metadata publishing and lookup for the rest of the
process lifetime:
get()/set()/remove()returned false forever, segmentregistration and lookup failed, and only restarting the process recovered.
We hit this in production with a managed Redis reached through a TCP load balancer
that reclaims connections idle for more than ~4000s. The connection is only touched
on segment (un)registration and on the first lookup of a given remote segment, so it
sits idle for hours; with no TCP keepalive nothing is sent during that window and the
load balancer silently reclaims the socket. Redis itself stayed healthy the whole
time — the broken state lived entirely in the client's cached context, so restarting
Redis did not help.
What changed
execLocked). Only nullreplies are retried: a
REDIS_REPLY_ERRORmeans the server answered and rejectedthe command, so retrying cannot help. GET/SET/DEL are idempotent, so the retry is
safe.
reconnect, and a construction-time failure is no longer terminal. Previously, a
Redis that was unreachable at startup left
client_null forever.timeout at all, so a slow or blackholed Redis could block the caller indefinitely
while holding
access_client_mutex_. The 3s values match the Redis client alreadyused by Mooncake Store HA
(
mooncake-store/src/ha/common/redis/redis_connection.cpp). Keepalive mattersbecause this connection is idle for long stretches, and the command timeout is what
makes recovery work when a middlebox drops the flow without resetting it.
getWithStatus()routed through the same retry path. [Bugfix][TransferEngine] Invalidate stale remote segments in syncSegmentCache #3072 added that overrideso
syncSegmentCache()can tell an absent key from a backend outage, but it issuedits own
redisCommand()withif (!client_) return kUnavailable;and no reconnect.Rebasing onto current main without touching it would have left [Bug]: Redis metadata connection never recovers after a disconnect, permanently breaking segment registration #4003 reproducible
through that entry point, and
syncSegmentCache()calls it periodically.get()nowdelegates to it, which also removes the duplicated GET handling. A null reply after
execLocked()means the backend is unreachable, so it maps tokUnavailableandnever to
kNotFound, preserving the invariant [Bugfix][TransferEngine] Invalidate stale remote segments in syncSegmentCache #3072 added.ctestconfiguredthe tree without
-DUSE_REDIS=ON, so the suite was compiled but never run; the jobthat enables
USE_REDISbuilds without runningctest. Adding the flag is enough —dependencies.shalready installslibhiredis-devon that runner and the top-levelCMakeLists.txtcallsenable_testing().No public interface changes.
MetadataStoragePlugin::Create()could already return aplugin with a dead connection, and no caller inspects connectivity, so behaviour for
callers is unchanged apart from now recovering.
One observable change for operators:
get()now logs a nil reply at WARNING instead ofERROR. An absent key is an expected outcome when resolving a segment that is not
registered yet, so the previous level reported a normal lookup miss as an error. The
message text is unchanged (
RedisStoragePlugin: unable to get <key> from <uri>), soanything matching on the text still matches, but alerting that keys on ERROR level will
no longer fire for lookup misses. The connection-level failure path — which is what
actually indicates a problem — is unaffected.
Module
mooncake-transfer-engine)mooncake-store)mooncake-reshard)mooncake-ep)mooncake-pg)mooncake-integration)mooncake-p2p-store)mooncake-wheel)mooncake-common)mooncake-rl)Type of Change
How Has This Been Tested?
Added
mooncake-transfer-engine/tests/transfer_metadata_redis_plugin_test.cpp, whichdrives the plugin through the public
MetadataStoragePlugin::Create("redis://...")factory against an in-process RESP fake server. A real Redis cannot be scripted to
drop a connection at an exact moment, which is precisely the condition under test, and
the fake keeps the test hermetic (no Redis server required in CI).
Cases covered:
setrecovers after the server drops the connectiongetrecovers after the server drops the connectiongetWithStatusrecovers after the server drops the connectiongetWithStatusreportskNotFoundfor an absent key while the server is up, andkUnavailable— neverkNotFound— once the server is goneremoverecovers after the server drops the connectionTest commands:
The binary is invoked directly above because
enable_testing()is only called from thetop-level
CMakeLists.txt, so a standalone Transfer Engine configure builds the testtarget but registers no CTest tests. In CI the suite now runs through
ctestfrom therepository-root configure, because this PR adds
-DUSE_REDIS=ONto the job that runsit.
Test results:
The run emits the expected recovery diagnostics, e.g.
RedisStoragePlugin: SET mooncake/node-1 failed on 127.0.0.1:17885: Server closed the connection; reconnecting and retrying once, followed by a successful retry.The same 9 cases also pass under
-fsanitize=address,undefined(190 ms, nosanitizer reports and no LeakSanitizer findings at exit), which covers the
redisContextandredisReplylifetimes on the reconnect path.These tests exercise the reconnect path via a peer that closes or refuses the
connection, so they cover the latched-error case rather than the silent-blackhole
case; the command timeout is therefore not exercised by them, which is also why the
whole suite finishes in 167 ms with no stalls.
Production validation: the reconnect, timeout and keepalive changes have been
cherry-picked onto v0.3.11.post1 and run for 5 days in a prefill/decode disaggregated
deployment on 8 H800 nodes, against the same managed Redis behind an L4 load balancer
described in #4003, where idle connections are reclaimed after roughly 4000s. Before the
patch the failure was fully deterministic there: SGLang (Store client) was restarted
roughly every 4 hours, and every restart reproduced it. The two intervals are different
things — the load balancer reclaims the connection about 4000s after the last Redis
access, but the dead context is only discovered at the next metadata operation, and a
restart is what forces those segment registrations and lookups to happen. Once
discovered, metadata publishing and lookup stayed broken for the remaining lifetime of
that process. After the patch the same restart cadence has not reproduced it once over
the 5-day run; a reclaimed connection now shows up only as a single warning followed by
a successful retry:
To be precise about what that run covers: the deployed build predates
getWithStatus()(added by #3072), so the routing of that entry point through the same retry path is
covered by the unit tests above rather than by the production run.
CI coverage: the job that runs
ctestpreviously configured without-DUSE_REDIS=ON,so these tests were compiled but never executed. This PR adds the flag to that job, so
transfer_metadata_redis_plugin_testnow runs in CI. It needs no Redis server.Checklist
./scripts/code_format.shchange; happy to document the timeout values if they should be configurable
lines in a single file and this is a bug fix rather than an architectural
change, so no RFC was filed. Issue [Bug]: Redis metadata connection never recovers after a disconnect, permanently breaking segment registration #4003 describes the bug.
Known limitations, called out deliberately
fresh connection instead of failing immediately, and it does so while holding
access_client_mutex_. Worst case for a blackholed Redis is roughly command timeoutplus reconnect plus command timeout. This is still bounded, whereas before this PR
the same situation could block forever with no timeout at all, but if maintainers
prefer a cooldown (skip reconnect attempts for N ms after a failure) I am glad to
add one.
no Mooncake code installs
SIG_IGN. This exposure is unchanged by this PR — thepre-existing code issued the same write on the same dead socket — so it is not
addressed here, but it seems worth a separate look.
set/removedo not inspectreply->type. A server-level error such as-READONLYduring a failover is still reported as success. This is pre-existingbehaviour, left untouched to keep the diff focused, and tracked separately as
requested.
AI Assistance Disclosure
AI assistance was used for code review, test scaffolding, and drafting this
description. The bug was diagnosed from a production incident on our side. I have
reviewed every changed line and can explain and defend the change end-to-end.