Lock backend to frontend when in progress cancel requests are in flight to prevent cancel race between two frontends - #1553
Open
KennanHunter wants to merge 6 commits into
Conversation
Pool::cancel used to snapshot the backend key under the lock and release it before sending the CancelRequest, so in transaction pooling the backend could be reassigned to another frontend before the packet landed and Postgres would interrupt the wrong query. A CancelLease RAII guard now pins the backend for the whole cancel; Pool::checkin defers on any backend with an outstanding lease so it can't be handed to another frontend mid-cancel. Server::cancel reads until Postgres closes the socket, extending the pin across the full server-side processing.
KennanHunter
force-pushed
the
1505_stale_cancel_request_fix
branch
from
September 15, 2026 22:53
35c120f to
9c7243a
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
- move_conns_to refuses to drain while a CancelLease is held, then goes through once the lease drops. - Server::cancel: peer closes cleanly, peer sends stray bytes then closes, and peer hangs past CANCEL_ACK_TIMEOUT (paused time so it doesn't actually sit for 5s).
Contributor
Author
|
Might want CANCEL_ACK_TIMEOUT to be configurable, not sure. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The issue
Fixes #1505, a cancel request can be made by the frontend, then before pgdog's cancel packet actually lands at Postgres, the physical backend that frontend was using can be returned to the pool and handed to a different frontend. The cancel then lands on the new frontend's query instead of the original one.
Concretely, in
Pool::cancel:The fix
Taken picks up a
backend_cancels_in_flightmap (backend pid → count). Pool::cancel takes aCancelLease, a small RAII guard in pool/cancel.rs, that bumps the count going in and drops it on the way out whether the cancel succeeded or not.Pool::checkinlooks at the map: if there's a pending cancel for that backend, the check-in waits until the count hits zero, otherwise it's the exact same sync path as today. I went with a count instead of a bool so a couple of cancels landing on the same backend at once just work.Pool::move_conns_tobails with a retryableError::CancelInFlightif anything's on the wire.Server::cancelnow hangs on until Postgres sends EOF. Postgres only does that after it's validated the request and poked the target backend, so that's our signal the cancel is really done. There's a 5s cap so a wedged server can't pin backends forever.Cost-wise, only waiters on that one backend notice if a cancel is in flight, the rest of the pool's connections are unblocked.
Tests:
test_cancel_request_cross_frontend_race(A starts a pg_sleep, cancel fires, B grabs the backend and runs its own pg_sleep, make sure B doesn't get 57014), cancel_leases_stack_on_same_backend, and the repro script from #1505 asintegration/python/test_cancel_race.py.Next steps
Style
Theres some duplication in logic between locking due to cancel requests, and locking due to advisory locks. I think for right now it's fine, but if we see more locking behaviors it might be a good idea to merge those in some way.
Could look something like this, with a single queue for locked connections on a pool side.
Scoping
An interesting idea longer-term is that the whole lease mechanism could disappear if pgdog tagged every forwarded statement with a
/* pgdog:<FrontendPid> */comment and routed cancels through a helper connection runningSELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE pid = $1 AND state = 'active' AND query LIKE '/* pgdog:F<n> */%'The problem is it gets pretty complicated injecting the tag cleanly across every query, especially when we consider mechanisms like
PREPARE,EXECUTE, andCOPY.Benchmarking
I think we should probably benchmark this on a cancel heavy workflow with actual network delay between frontend/backend, to verify that nothing goes haywire. Lmk if there's any changes you'd like to see.