-
Notifications
You must be signed in to change notification settings - Fork 2.7k
fix(async): avoid asyncio.timeout on PubSub poll path (#3748) #4299
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
6d2ef91
4912242
ed058f6
29c6aee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| from ..utils import SENTINEL | ||
| from .base import ( | ||
| AsyncPushNotificationsParser, | ||
| BufferedResponseIncomplete, | ||
| PushNotificationsParser, | ||
| _AsyncRESPBase, | ||
| _RESPBase, | ||
|
|
@@ -175,7 +176,11 @@ async def handle_pubsub_push_response(self, response): | |
| return response | ||
|
|
||
| async def read_response( | ||
| self, disable_decoding: bool = False, push_request: bool = False | ||
| self, | ||
| disable_decoding: bool = False, | ||
| push_request: bool = False, | ||
| *, | ||
| buffered_only: bool = False, | ||
| ): | ||
| if not self._connected: | ||
| raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) | ||
|
|
@@ -184,19 +189,30 @@ async def read_response( | |
| self._buffer += b"".join(self._chunks) | ||
| self._chunks.clear() | ||
| self._pos = 0 | ||
| response = await self._read_response( | ||
| disable_decoding=disable_decoding, push_request=push_request | ||
| ) | ||
| try: | ||
| response = await self._read_response( | ||
| disable_decoding=disable_decoding, | ||
| push_request=push_request, | ||
| buffered_only=buffered_only, | ||
| ) | ||
| except BufferedResponseIncomplete: | ||
| if buffered_only: | ||
| return None | ||
|
Comment on lines
+198
to
+200
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With the pure-Python RESP3 parser, a zero-timeout normal read that contains a complete push followed by an incomplete command reply invokes the push handler before raising this exception. Returning here leaves the buffer intact, so the next poll reparses the push and invokes the invalidation or maintenance handler a second time; retain consumed state or defer handler effects until the full response sequence is parsed. AGENTS.md reference: AGENTS.md:L121-L124 Useful? React with 👍 / 👎. |
||
| raise | ||
| # Successfully parsing a response allows us to clear our parsing buffer | ||
| self._clear() | ||
| return response | ||
|
|
||
| async def _read_response( | ||
| self, disable_decoding: bool = False, push_request: bool = False | ||
| self, | ||
| disable_decoding: bool = False, | ||
| push_request: bool = False, | ||
| *, | ||
| buffered_only: bool = False, | ||
| ) -> Union[EncodableT, ResponseError, None]: | ||
| if not self._stream or not self.encoder: | ||
| raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) | ||
| raw = await self._readline() | ||
| raw = await self._readline(buffered_only=buffered_only) | ||
| response: Any | ||
| byte, response = raw[:1], raw[1:] | ||
|
|
||
|
|
@@ -206,7 +222,7 @@ async def _read_response( | |
| # server returned an error | ||
| if byte in (b"-", b"!"): | ||
| if byte == b"!": | ||
| response = await self._read(int(response)) | ||
| response = await self._read(int(response), buffered_only=buffered_only) | ||
| response = response.decode("utf-8", errors="replace") | ||
| error = self.parse_error(response) | ||
| # if the error is a ConnectionError, raise immediately so the user | ||
|
|
@@ -236,22 +252,34 @@ async def _read_response( | |
| return response == b"t" | ||
| # bulk response | ||
| elif byte == b"$": | ||
| response = await self._read(int(response)) | ||
| response = await self._read(int(response), buffered_only=buffered_only) | ||
| # verbatim string response | ||
| elif byte == b"=": | ||
| response = (await self._read(int(response)))[4:] | ||
| response = (await self._read(int(response), buffered_only=buffered_only))[ | ||
| 4: | ||
| ] | ||
| # array response | ||
| elif byte == b"*": | ||
| response = [ | ||
| (await self._read_response(disable_decoding=disable_decoding)) | ||
| ( | ||
| await self._read_response( | ||
| disable_decoding=disable_decoding, | ||
| buffered_only=buffered_only, | ||
| ) | ||
| ) | ||
| for _ in range(int(response)) | ||
| ] | ||
| # set response | ||
| elif byte == b"~": | ||
| # redis can return unhashable types (like dict) in a set, | ||
| # so we always convert to a list, to have predictable return types | ||
| response = [ | ||
| (await self._read_response(disable_decoding=disable_decoding)) | ||
| ( | ||
| await self._read_response( | ||
| disable_decoding=disable_decoding, | ||
| buffered_only=buffered_only, | ||
| ) | ||
| ) | ||
| for _ in range(int(response)) | ||
| ] | ||
| # map response | ||
|
|
@@ -261,25 +289,34 @@ async def _read_response( | |
| # became defined to be left-right in version 3.8 | ||
| resp_dict = {} | ||
| for _ in range(int(response)): | ||
| key = await self._read_response(disable_decoding=disable_decoding) | ||
| key = await self._read_response( | ||
| disable_decoding=disable_decoding, | ||
| buffered_only=buffered_only, | ||
| ) | ||
| resp_dict[key] = await self._read_response( | ||
| disable_decoding=disable_decoding, push_request=push_request | ||
| disable_decoding=disable_decoding, | ||
| push_request=push_request, | ||
| buffered_only=buffered_only, | ||
| ) | ||
| response = resp_dict | ||
| # push response | ||
| elif byte == b">": | ||
| response = [ | ||
| ( | ||
| await self._read_response( | ||
| disable_decoding=disable_decoding, push_request=push_request | ||
| disable_decoding=disable_decoding, | ||
| push_request=push_request, | ||
| buffered_only=buffered_only, | ||
| ) | ||
| ) | ||
| for _ in range(int(response)) | ||
| ] | ||
| response = await self.handle_push_response(response) | ||
| if not push_request: | ||
| return await self._read_response( | ||
| disable_decoding=disable_decoding, push_request=push_request | ||
| disable_decoding=disable_decoding, | ||
| push_request=push_request, | ||
| buffered_only=buffered_only, | ||
| ) | ||
| else: | ||
| return response | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1138,6 +1138,15 @@ async def listen(self) -> AsyncIterator[MonitorCommandInfo]: | |
| yield await self.next_command() | ||
|
|
||
|
|
||
| async def _await_connection_can_read(conn: "Connection", timeout: float = 0) -> bool: | ||
| """Call ``can_read`` on a connection, tolerating legacy no-arg overrides.""" | ||
| can_read = conn.can_read | ||
| try: | ||
| return await can_read(timeout=timeout) | ||
| except TypeError: | ||
| return await can_read() | ||
|
Comment on lines
+1144
to
+1147
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a legacy custom connection whose no-argument AGENTS.md reference: AGENTS.md:L112-L114 Useful? React with 👍 / 👎. |
||
|
|
||
|
|
||
| class PubSub: | ||
| """ | ||
| PubSub provides publish, subscribe and listen support to Redis channels. | ||
|
|
@@ -1445,14 +1454,24 @@ async def parse_response(self, block: bool = True, timeout: float = 0): | |
| # removed. That swap is a breaking change to the | ||
| # Connection.read_response signature so it must wait for a | ||
| # major release. | ||
| read_timeout = math.inf if block else timeout | ||
| response = await self._execute( | ||
| conn, | ||
| conn.read_response, | ||
| timeout=read_timeout, | ||
| disconnect_on_error=False, | ||
| push_request=True, | ||
| ) | ||
| async def try_read(): | ||
| if not block: | ||
| if not await _await_connection_can_read(conn, timeout): | ||
| if timeout == 0: | ||
| # Match PubSub.run(): yield so other tasks make progress | ||
| # when polling with timeout=0 and no buffered data. | ||
| await asyncio.sleep(0) | ||
| return None | ||
| read_timeout = timeout | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the first bytes of a partial RESP frame arrive just before AGENTS.md reference: AGENTS.md:L121-L124 Useful? React with 👍 / 👎. |
||
| else: | ||
| read_timeout = math.inf | ||
| return await conn.read_response( | ||
| timeout=read_timeout, | ||
| disconnect_on_error=False, | ||
| push_request=True, | ||
| ) | ||
|
lllakshit marked this conversation as resolved.
|
||
|
|
||
| response = await self._execute(conn, try_read) | ||
|
|
||
| if conn.health_check_interval and response in self.health_check_response: | ||
| # ignore the health check message as user might not expect it | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.