Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 4 additions & 9 deletions redis/asyncio/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -1930,16 +1930,11 @@ def set_nodes(
for name in list(old.keys()):
if name not in new:
# Node is removed from cache before disconnect starts,
# so it won't be found in lookups during disconnect
# Mark active connections so in-flight commands can
# finish, then disconnect them when their current
# operation completes. Free connections can be
# disconnected immediately.
# so it won't be found in lookups during disconnect.
# We fully disconnect the node so that all its connections
# (in-use and free) are closed immediately, preventing leaks.
removed_node = old.pop(name)
removed_node.update_active_connections_for_reconnect()
task = asyncio.create_task(
removed_node.disconnect_free_connections()
)
task = asyncio.create_task(removed_node.disconnect())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Let in-flight commands finish before disconnecting removed nodes

When a topology refresh removes a node after a mutating command has been sent but before its response is read, this immediate disconnect makes the command raise ConnectionError; RedisCluster.execute_command() then retries it on the refreshed topology by default. If the server already applied a command such as INCR or LPUSH, the retry applies it again and silently duplicates the write. Removed nodes should reject new work while marking active connections for disconnection only after their current operation completes.

AGENTS.md reference: AGENTS.md:L158-L162

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClusterNode.disconnect() gathers with return_exceptions=True and then re-raises the first exception, unlike disconnect_free_connections() which swallowed them. Since this is a fire-and-forget task whose only done_callback is discard, a node that goes away mid-topology-change (socket close raising) now turns into an unretrieved "Task exception was never retrieved" on the loop instead of a quiet cleanup. Probably worth wrapping the create_task target in a small helper that logs instead of raising.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled exception in fire-and-forget disconnect task

Medium Severity

ClusterNode.disconnect() gathers connection disconnects with return_exceptions=True but then re-raises the first exception. Since this is a fire-and-forget task whose only done-callback is discard, any exception (e.g., a node disappearing mid-close) becomes an unhandled "Task exception was never retrieved" error on the event loop. The codebase already handles this correctly in _disconnect_and_release (which wraps disconnect() in try/except and logs), but the new code omits that protection.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1b92aa9. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClusterNode.disconnect() re-raises the first exception out of its gather, disconnect_free_connections() swallowed them (return_exceptions=True, no re-raise). This is a bare create_task whose only done_callback is the set discard, so nothing ever retrieves the result — a socket that errors on close now surfaces as "Task exception was never retrieved" on the loop instead of being ignored like before. Probably want to wrap it in a coroutine that logs and drops.

self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
Comment on lines +1937 to 1939

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retrieve failures from detached disconnect tasks

If any connection's disconnect() times out or otherwise raises, ClusterNode.disconnect() re-raises that failure, but this detached task is only given a callback that discards it from the set without retrieving its exception. Once the task is released, asyncio reports Task exception was never retrieved through the application's loop exception handler. Add a completion callback that consumes and logs the exception, as other background disconnect sites in this package do.

Useful? React with 👍 / 👎.


Expand Down
22 changes: 10 additions & 12 deletions tests/test_asyncio/test_cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -3140,11 +3140,12 @@ async def test_move_node_to_end_of_cached_nodes(self) -> None:
assert nodes_cache_names == [node2.name, node1.name, node3.name]

@pytest.mark.fixed_client
async def test_set_nodes_disconnects_removed_node_connections_after_use(
async def test_set_nodes_disconnects_removed_node_connections_immediately(
self,
default_host: str,
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
) -> None:
"""
Test removed nodes let in-flight commands finish, then disconnect.
Test removed nodes are fully disconnected immediately, including in-use connections.
"""

class FakeConnection:
Expand Down Expand Up @@ -3201,18 +3202,15 @@ def should_reconnect(self) -> bool:
assert nodes_manager.nodes_cache == {}
assert free_conn.disconnect_called is True
assert free_conn.is_connected is False
assert active_conn.reconnect is True
assert active_conn.disconnect_called is False
assert active_conn.is_connected is True

active_conn.response_ready.set()
assert await command == b"OK"

assert active_conn.disconnect_called is True
assert active_conn.is_connected is False
assert active_conn in node._free
assert free_conn.reconnect is False
assert active_conn.reconnect is False

# Cancel the hanging command task since FakeConnection.disconnect doesn't interrupt read_response
command.cancel()
try:
await command
except asyncio.CancelledError:
pass

@pytest.mark.fixed_client
async def test_move_node_to_end_of_cached_nodes_nonexistent(self) -> None:
Expand Down
Loading