Skip to content

perf(connection): pipeline the sync connection handshake - #4203

Open
elena-kolevska wants to merge 2 commits into
redis:masterfrom
elena-kolevska:handshake-pipelining
Open

perf(connection): pipeline the sync connection handshake#4203
elena-kolevska wants to merge 2 commits into
redis:masterfrom
elena-kolevska:handshake-pipelining

Conversation

@elena-kolevska

@elena-kolevska elena-kolevska commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Description of change

The sync connection handshake in AbstractConnection.on_connect_check_health issued each setup command as a separate blocking round-trip. For a typical RESP3 connection with a client name and a selected DB this was ~5 sequential round-trips (HELLO/AUTH, optional CLIENT MAINT_NOTIFICATIONS, CLIENT SETNAME, CLIENT SETINFO LIB-NAME, CLIENT SETINFO LIB-VER, SELECT), paid on every connection open, pool growth, and reconnect.

Pipeline the tail of the handshake: after HELLO/AUTH (which stays its own round-trip because its reply drives the RESP2->RESP3 parser upgrade, the pre-6.0 AUTH retry, and proto validation), send the remaining commands back-to-back without blocking on a reply between them, then read the replies back in send order.

All requests are on the wire before the first read blocks, so the whole tail costs a single round-trip. Each command keeps its original validation / error-swallowing semantics, and per-command check_health preserves the original health-check behavior.

This cuts the handshake from ~5 round-trips to 2. The saving is ~4 x RTT per connection (depending on what's enabled).

Add benchmarks/connection_handshake.py to measure connection-handshake latency, with an optional --delay-ms that injects per-round-trip latency so the round-trip reduction is observable on localhost, and a --maint-notifications knob to include the CLIENT MAINT_NOTIFICATIONS command in the measured handshake.

Add a regression test asserting the full pipelined tail (maintenance notifications in auto mode with an error reply, plus client_name and db) reads its replies back in order and a following command still succeeds.

Pull Request check-list

  • Do tests and lints pass with this change?
  • Do the CI tests pass with this change (enable it first in your forked repo and wait for the github action build to finish)?
  • Is the new or changed code fully tested?
  • Is a documentation update included (if this change modifies existing APIs, or introduces new ones)?
  • Is there an example added to the examples folder (if applicable)?

Note

Medium Risk
Every new sync connection and reconnect goes through the rewritten handshake; incorrect reply ordering would break connects, though behavior is intended to match the prior sequential path and is covered by new tests.

Overview
Sync Connection.on_connect_check_health no longer blocks on each reply for setup commands after HELLO/AUTH. It sends the optional tail—CLIENT MAINT_NOTIFICATIONS, CLIENT SETNAME, CLIENT SETINFO, and SELECT—back-to-back, then drains replies in send order via deferred handlers, so the tail costs one round-trip instead of roughly one per command. HELLO/AUTH stays sequential because those replies drive parser upgrades and auth retries.

Maintenance notifications are refactored so the command can join that pipelined tail (_should_enable_maint_notifications, split send/response helpers) instead of a separate post-handshake round-trip when enabled.

Adds benchmarks/connection_handshake.py (optional per–round-trip --delay-ms and --maint-notifications) and a test that asserts wire order and that a swallowed MAINT error in auto mode does not desync later replies.

Reviewed by Cursor Bugbot for commit 234e2be. Bugbot is set up for automated code reviews on this repo. Configure here.

The sync connection handshake in AbstractConnection.on_connect_check_health
issued each setup command as a separate blocking round-trip. For a typical
RESP3 connection with a client name and a selected DB this was ~5 sequential
round-trips (HELLO/AUTH, optional CLIENT MAINT_NOTIFICATIONS, CLIENT SETNAME,
CLIENT SETINFO LIB-NAME, CLIENT SETINFO LIB-VER, SELECT), paid on every
connection open, pool growth, and reconnect.

Pipeline the tail of the handshake: after HELLO/AUTH (which stays its own
round-trip because its reply drives the RESP2->RESP3 parser upgrade, the
pre-6.0 AUTH retry, and proto validation), send the remaining commands
back-to-back without blocking on a reply between them, then read the replies
back in send order. All requests are on the wire before the first read blocks,
so the whole tail costs a single round-trip. Each command keeps its original
validation / error-swallowing semantics, and per-command check_health preserves
the original health-check behavior. This mirrors the async stack
(redis/asyncio/connection.py). When maintenance notifications are enabled, the
CLIENT MAINT_NOTIFICATIONS command is folded into the same pipelined tail
(sent first, read first) instead of costing its own round-trip; the maintenance
helpers are refactored (_should_enable_maint_notifications,
_maint_notifications_command_args, _send_maint_notifications_command,
_add_maint_notifications_to_handshake, _handle_maint_notifications_response)
with no change to the behavior of callers that enable notifications outside of
on_connect.

This cuts the handshake from ~5 round-trips to 2. The saving is ~4 x RTT per
connection: measured means at protocol 3 with client_name + db (n>=1000) went
7.64->3.47 ms at 1 ms/RTT and 32.0->13.5 ms at 5 ms/RTT.

Add benchmarks/connection_handshake.py to measure connection-handshake latency,
with an optional --delay-ms that injects per-round-trip latency so the
round-trip reduction is observable on localhost, and a --maint-notifications
knob to include the CLIENT MAINT_NOTIFICATIONS command in the measured
handshake.

Add a regression test asserting the full pipelined tail (maintenance
notifications in auto mode with an error reply, plus client_name and db) reads
its replies back in order and a following command still succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread redis/connection.py
self.read_response()
except ResponseError:
pass
def _read_setname_response():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we lift these three read handlers out of on_connect_check_health into methods: _read_setname_response, _read_setinfo_response and _read_select_response?
They capture nothing from local scope (only self), so nesting doesn't buy the usual closure benefit, and it re-allocates three function objects on every connect — a path this PR is specifically optimizing.
It'd also match _handle_maint_notifications_response, which is already a proper method; right now the four handlers play the same role in two different shapes.

_read_setname_response and _read_select_response are identical apart from the error string, so they can collapse into one helper:

def _read_ok_or_raise(self, error_message):
    if str_if_bytes(self.read_response()) != "OK":
        raise ConnectionError(error_message)

with the send site appending functools.partial(self._read_ok_or_raise, "Error setting client name"), plus a small _read_optional_setinfo for the swallow case.

Comment thread redis/connection.py
self.activate_maint_notifications_handling_if_enabled(check_health=check_health)
# The tail of the handshake (optional CLIENT MAINT_NOTIFICATIONS, then
# CLIENT SETNAME / SETINFO / SELECT) does not affect control flow -- the replies
# are only validated or discarded. So we pipeline it: send every command first

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think the wording here should be changed a little - we are not really pipelining the commands - maybe it would be better to state that we are optimizing the flow.
In redis-py's context is associated with collecting the commands and sending them all with one write, so this comment might be confusing.

Comment thread redis/connection.py
# are only validated or discarded. So we pipeline it: send every command first
# (without blocking on a reply between them), then read the replies back in send
# order. All requests are on the wire before we block on the first read, so the
# whole tail costs a single round-trip instead of one per command. This mirrors

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since not all commands in the async handshake are handled with this approach, a TODO note there would be a nice addition for the commands that are handled completely separately - I think the maintenance notifications flow is added as it was in the sync implementation before this optimisation.

@petyaslavova petyaslavova added the maintenance Maintenance (CI, Releases, etc) label Jul 22, 2026
@elena-kolevska
elena-kolevska marked this pull request as ready for review July 22, 2026 09:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintenance Maintenance (CI, Releases, etc)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants