fix(asyncio): raise on EOF in can_read() so pools replace server-closed connections - #4260
Conversation
…ls reconnect them The async parsers folded EOF into can_read()'s True return. Since redis#4177, ensure_connection() skips the pending-data check when maintenance notifications are enabled (pending push messages are legitimate), which also swallowed the EOF signal: a connection the server closed while idle in the pool was handed out, and the next command failed with 'Connection closed by server.' instead of being transparently replaced. Raise ConnectionError on EOF instead, matching what the sync SocketBuffer has always done, so ensure_connection()'s existing reconnect branch handles it regardless of the push-data exemption. Fixes redis#4252
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0ea0a16dd0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Match the sync parsers' ordering: a reply or push notification that is already buffered (hiredis reader / parser buffer) stays readable even if the server has since closed the connection, so checkout doesn't discard a pending MOVING/maintenance notification. EOF with nothing buffered still raises, which is the redis#4252 case.
petyaslavova
left a comment
There was a problem hiding this comment.
Hey @Brumbelow, thank you for your contribution!
A few things need to be addressed before we can merge this PR:
- Please correct the root-cause attribution in the description. #4063 shipped in 8.0.1, which recovers correctly with the same "EOF ⇒ True" parser code — the async
can_read()implementations are byte-identical betweenv8.0.1andmaster, so it isn't a co-cause. The regression is only theand not self.maint_notifications_enabled()guard added in #4177: with RESP3 over TCP the pool auto-createsMaintNotificationsConfig(enabled="auto"), so the exemption is on by default and the reconnect branch is never entered. RESP2 gets no handler, which is why it still recovers. - The comment in
tests/test_asyncio/test_scenario/test_maint_notifications.pythat explains itsexcept RedisConnectionErrorstill describes the old behaviour ("can_read() reports True on EOF ... so the drain enters read_response()"). Please update it. - Parametrize the new pool test over
_AsyncHiredisParseras well, and add a real-server regression test for #4252 usingclient_kill_filter(_id=...)on an idle pooled connection — the current test asserts the branch, not end-to-end recovery.
And yes, please do file the sync-Retry-in-an-async-pool degradation separately — good catch, and it deserves its own guard.
… comment - Parametrize the pool checkout test over _AsyncHiredisParser (skipped when hiredis is not installed), stubbing the reader that on_connect() would normally create. - Add an end-to-end regression test for redis#4252: kill an idle pooled connection server-side via client_kill_filter(_id=...), with retries disabled so recovery can only come from pool checkout. Fails on pre-fix master, passes with the fix (verified against a local redis-server 7.0.15, RESP3 default). - Update the drain-loop comment in the async maint-notifications scenario test that still described the old EOF-returns-True behavior.
|
Hello @petyaslavova! TY for those catches. This should be good to go now, updated description and tests |
Description of change
Fixes #4252.
In
redis.asyncio, a pooled connection that the server closed while it sat idle (idle timeout,CLIENT KILL, server-side reap) is handed out to the next command instead of being replaced, and the command fails withConnectionError: Connection closed by server.. Regression in 8.1.0 under RESP3 (the default); 8.0.1 recovers transparently.Root cause: the
and not self.maint_notifications_enabled()guard that #4177 added toensure_connection()'s checkout probe. The async parsers' non-destructivecan_read()(#4063, shipped in 8.0.1, which recovers correctly) reports a server-closed stream by returningTrue— EOF folded into "has readable data" — and in 8.0.1 thatTrueunconditionally hits theConnection has databranch, which disconnects and reconnects. With RESP3 over TCP the pool auto-createsMaintNotificationsConfig(enabled="auto"), so since #4177 the exemption is on by default and the reconnect branch is never entered: the dead connection passes checkout. RESP2 connections get no maintenance-notifications handler, which is why RESP2 still recovers.The sync side doesn't have this problem:
SocketBuffer.can_read()raisesConnectionError(SERVER_CLOSED_CONNECTION_ERROR)on EOF, which the pool'sexcept (ConnectionError, TimeoutError, OSError)branch turns into disconnect + reconnect regardless of any exemption.This change makes the async parsers (
_AsyncRESPBase,_AsyncHiredisParser) behave the same way: raiseConnectionErroronat_eof()instead of returningTrue, with already-buffered data still reported first (matching the sync parsers' buffer-first ordering, so a pending reply or push notification is delivered rather than discarded).ensure_connection()'s existing reconnect branch then replaces the dead connection whether or not the pending-push-data exemption applies — the closed-connection signal can no longer be swallowed by that guard, rather than only reordering the guard itself. The asyncparser.can_read()is reached only throughConnection.can_read(), whose only caller is the pool checkout path, so the blast radius is that seam.Verified with the reproduction from #4252 (server closes the idle pooled connection, health check due): before this change RESP3 raises and RESP2 recovers; after it both recover, and 8.0.1 behavior is restored.
One additional observation from the issue's repro, out of scope here: it passes the sync
redis.retry.Retryinto the async pool. Awaiting the synccall_with_retrysilently degrades it to a single attempt with no failure callback, which is why the health-checkPINGfailure surfaced unretried. Withredis.asyncio.retry.Retrythe client recovers even without this fix (at the cost of a failed attempt and reconnect churn). Filed separately as #4262.Pull Request check-list