Skip to content

Serve buffered client requests before sending the shutdown notice - #1567

Draft
scottjacobsen wants to merge 1 commit into
pgdogdev:mainfrom
scottjacobsen:scottjacobsen/serve-buffered-requests-before-shutdown-notice
Draft

scottjacobsen wants to merge 1 commit into
pgdogdev:mainfrom
scottjacobsen:scottjacobsen/serve-buffered-requests-before-shutdown-notice

Conversation

@scottjacobsen

Copy link
Copy Markdown
Contributor

What

On graceful shutdown (SIGINT or admin SHUTDOWN) Client::run raced the shutdown token against the client socket:

  • with tokio's default random select! order, a request whose bytes had already been read could lose to the shutdown branch and be dropped, and the socket was closed with unread data, so drivers saw a connection reset instead of the 57P01 notice;
  • a partially received extended-protocol request (Parse+Bind without Sync) survived the cancelled buffer() poll only to be wiped by client_request.clear() on re-entry, so the client got a backend error for its Execute+Sync before the FATAL.

Changes

  • select! is biased with the shutdown wake-up polled last, so a request that is ready when the loop polls is served before the notice.
  • The notice is deferred while stream_buffer holds unconsumed bytes or client_request.is_partial().
  • buffer() only clears a completed request. This also fixes the same message loss when an async backend message (for example a NotificationResponse) arrived between a client's Parse and Sync.
  • Logging: each notice is logged at debug, and the shutdown summary reports idle clients notified versus clients waited on, from one comms().clients() snapshot (idle and not holding a lock).

What this does not cover

A request whose bytes arrive after the loop has decided to send the notice is still cut off; that is inherent to a server-initiated close. A client that sends part of a request and then goes quiet is now waited on until shutdown_timeout, like an idle-in-transaction client, instead of being disconnected immediately.

Tests

Two new tests in graceful_shutdown.rs: a Query already on the socket when shutdown starts is answered before 57P01, and Parse+Bind, shutdown, Execute+Sync completes before 57P01 (fails without the buffer() change). cargo fmt and cargo clippy --all-targets are clean; the four graceful shutdown tests pass.

Context: found while teaching Rails' ActiveRecord to handle the shutdown notice. libpq treats an ErrorResponse received while idle as a notice, so the next statement hits a closed socket.

🤖 Generated with Claude Code

On graceful shutdown the client loop raced the shutdown token against
the client socket. A request whose bytes had already been read could be
dropped, and a partially received extended-protocol request was wiped
when buffer() re-entered and cleared it, so the client saw a backend
error or a connection reset instead of the 57P01 notice.

The select! is now biased with the shutdown wake-up polled last, the
notice is deferred while the stream buffer holds unconsumed bytes or the
client request is only partially received, and buffer() only clears a
completed request. The shutdown summary reports how many connected
clients are idle and notified immediately versus waited on, and each
notice is logged at debug level.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@levkk

levkk commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

👁️

let clients = comms.clients();
let idle_clients = clients
.values()
.filter(|client| client.stats.state == State::Idle && !client.stats.locked)

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.

Why the !locked check?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants