[Service Bus] Drain receive link and release messages on receiver close (#42917)#47999
Conversation
On close, a PEEK_LOCK receiver dropped messages that had been prefetched into the client buffer but not yet returned to the application, leaving them locked at the broker until lock expiry (delaying redelivery and inflating delivery_count). Both the sync (ReceiveClient.close) and async (ReceiveClientAsync.close_async) pyamqp paths now release buffered-but- unconsumed messages with the `released` disposition before clearing the buffer. Gated on PEEK_LOCK (ReceiverSettleMode.Second) and guarded so a faulted link cannot block close. Adds deterministic regression tests. Fixes Azure#42917 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef8092b-2a9f-4862-aef1-680eeb2e97f0
There was a problem hiding this comment.
Pull request overview
Fixes delayed redelivery of buffered PEEK_LOCK messages when Service Bus receivers close.
Changes:
- Releases buffered messages during synchronous and asynchronous close.
- Adds seven regression tests.
- Documents the fix in the changelog.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
CHANGELOG.md |
Documents the receiver-close fix. |
_pyamqp/client.py |
Drains buffered messages during synchronous close. |
_pyamqp/aio/_client_async.py |
Implements asynchronous buffer draining. |
test_receiver_drain_on_close.py |
Tests settlement modes, empty buffers, and failures. |
Reworks the Azure#42917 fix to keep the vendored _pyamqp package untouched (it is shared with azure-eventhub and maintained in lockstep). The earlier edit to _pyamqp/client.py and _client_async.py is reverted; drain-on-close now lives in the Service Bus transport layer: - PyamqpTransport.drain_receive_link_and_release_messages (+ async twin) sends a drain flow (flow link_credit=0, drain=True), pumps the connection until quiescent (bounded by RECEIVE_LINK_DRAIN_TIMEOUT), then releases buffered/in-flight messages with the `released` disposition. No-op for the deprecated uamqp transport. - ServiceBusReceiver._close_handler calls it, gated to non-session PEEK_LOCK receivers (parity with the .NET SDK). Draining in-flight (not just already-buffered) messages fully eliminates the delayed redelivery and inflated delivery count in the reporter's scenario. Fixes Azure#42917 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef8092b-2a9f-4862-aef1-680eeb2e97f0
- Always release buffered deliveries when the link is open. The previous early-return on zero link credit also skipped releasing already-buffered messages, re-introducing the bug for prefetch (prefetch_count > 0) receivers whose credit is exhausted while _received_messages still has deliveries. Only the drain flow is now gated on outstanding credit. - Harden drain quiescence: pump with batch=outstanding so a multi-frame (fragmented) transfer is assembled within a listen() cycle, and require two consecutive idle cycles before concluding (still deadline-bounded). pyamqp does not surface the broker drain response, so queue-growth remains the signal. - Tests: add no-credit-still-releases (sync/async) and empty-buffer no-op; assert closed link settles nothing. Fixes Azure#42917 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef8092b-2a9f-4862-aef1-680eeb2e97f0
) The drain deadline was only checked between listen() calls, so a large socket_timeout (or a continuously-available batch) could let a single blocking read overrun RECEIVE_LINK_DRAIN_TIMEOUT. Each listen() now waits at most the remaining drain budget (wait = min(socket_timeout, remaining)), so close() is bounded by the cap regardless of socket_timeout. The bounded tests now use a listen mock that actually blocks for its wait argument with a large socket_timeout and assert close() returns within the cap. Fixes Azure#42917 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef8092b-2a9f-4862-aef1-680eeb2e97f0
|
@microsoft-github-policy-service agree company="Microsoft" |
EldertGrootenboer
left a comment
There was a problem hiding this comment.
Not ready to merge yet: CI is red on the pylint guidelines checker.
Rename drain_receive_link_and_release_messages_async in the three async transport files; it's over the 40-char limit (C4751), e.g. drain_and_release_messages_async.
The fix itself looks solid.
A few smaller things, none blocking:
The except Exception: pass swallows the error with no log; a debug log there would help when triaging a close that didn't release.
Drain and release share one try block, so if the drain flow throws, the already-buffered messages don't get released either.
The "parity with .NET" wording is slightly off: .NET gates on non-session only and just drains, while this also releases the prefetch buffer, so it's a superset.
…ure still releases, add debug logs - Rename drain_receive_link_and_release_messages -> drain_and_release_messages (and _async) to satisfy the 40-char method-name limit (C4751). - Split the drain and release into separate try/except blocks so a drain failure still releases buffered messages. - Add _LOGGER.debug on the drain-failure and release-failure paths. - Update tests to assert the buffer is still released when drain fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef8092b-2a9f-4862-aef1-680eeb2e97f0
|
Thanks Eldert — all addressed in
Also re-verified all 19 tests fail with the fix disabled. Ready for another look. |
|
The rename, the split try, and the debug logs all look good. Two things I'd still like before this merges, then a couple of smaller ones. The The release loop wraps the whole Smaller: The async drain tests don't cover the null-link, closed-link, or empty-buffer cases that the sync class does, even though the async impl has the same guards.
One optional thing: the tests assert the |
… clarify .NET gate - Move the release try inside the loop so one bad delivery tag no longer strands the rest; handle the empty()/get_nowait race via queue.Empty. - Give the async transport its own _LOGGER (was imported from the sync module). - Add async parity tests for the null-link, closed-link and empty-buffer guards. - Reword the docstring and receiver comments so only the non-session gate is attributed to .NET; the PEEK_LOCK gate is a Python-specific refinement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef8092b-2a9f-4862-aef1-680eeb2e97f0
|
All addressed in
|
EldertGrootenboer
left a comment
There was a problem hiding this comment.
CI is green now, and the rename plus the try-split, per-message release, and logging all landed cleanly.
Thanks for the quick turnaround. Approving.
SwayGom
left a comment
There was a problem hiding this comment.
Approving — this is a strong, well-scoped fix with excellent test coverage (16 transport + 6 gating tests, sync+async, verified to fail when the fix is disabled) and a convincing live A/B (0 lock-expiry redeliveries). The transport-layer approach (leaving vendored _pyamqp untouched), the use of released (so delivery_count isn't bumped), the separate try-blocks so a drain failure still releases the buffer, and using listen() instead of do_work() (which would re-issue credit and undo the drain) are all correct and nicely commented. Approving with a couple of clarifying questions and small nits below — none blocking.
Questions
Q1 — Session receivers are excluded; please confirm they don't have the same leak. The gate is PEEK_LOCK and not self._session, and the comment justifies the non-session part as ".NET parity." I believe sessions are safe because closing a session receiver releases the session lock, making its unsettled messages immediately available — but that only holds if close releases the session lock immediately. If it instead relies on session-lock expiry, session receivers have the same delayed-redelivery / inflated-delivery_count problem and should also drain+release. Could you confirm which it is, and capture the session-lock-release rationale in the comment (it currently reads as parity, not mechanism)?
Q2 — Worst-case close() latency when _socket_timeout is None. When _socket_timeout is None, wait = remaining (up to the full RECEIVE_LINK_DRAIN_TIMEOUT = 5s). test_bounded_by_deadline_even_with_blocking_listen correctly proves it stays bounded — but is a ~5s worst-case acceptable for close()? In practice, is _socket_timeout effectively always non-None (keeping idle cycles sub-second)? If it can be None, consider capping wait with a small idle-poll interval so normal closes stay snappy while still bounded.
Minor / nits (non-blocking)
- 2-idle-cycle quiescence heuristic: since pyamqp drops the drain echo, quiescence is inferred from 2 consecutive empty
listen()s. A slow in-flight transfer arriving after 2 idle cycles (but before the deadline) would be missed and left in-flight → still locked. Bounded and empirically 0 in your A/B, so fine — worth a one-line note on the tradeoff (and whether the threshold should scale withoutstandingon high-latency links). task_done()skipped on settle failure (both sync and async release loops): it runs only after a successfulsettle_messages, so the queue accounting drifts on theexceptpath. Harmless on close, but afinallykeeps it consistent:
try:
handler.settle_messages(frame[1], frame[2], "released")
except Exception: # pylint: disable=broad-except
_LOGGER.debug("Releasing a buffered message on close failed.", exc_info=True)
finally:
handler._received_messages.task_done()(and the await/settle_messages_async equivalent in the async transport.)
- Sync/async duplication: the two
drain_and_release_messagesbodies are ~55 near-identical lines. Understandable given the sync/async split and it reads clearly — just flagging, not blocking.
Nice work — this cleanly resolves #42917.
| handler._connection.listen(wait=wait, batch=outstanding) | ||
| if handler._received_messages.qsize() == before: | ||
| idle_cycles += 1 | ||
| if idle_cycles >= 2: |
There was a problem hiding this comment.
This 2 is the one drain knob that didn't get promoted to a constant, unlike RECEIVE_LINK_DRAIN_TIMEOUT above it. Can we pull it out as RECEIVE_LINK_DRAIN_IDLE_CYCLES = 2 next to the timeout, so both knobs sit together and the "why 2" comment attaches to a name? Same literal lives in the async twin at _pyamqp_transport_async.py:344.
Summary
Fixes #42917. A
PEEK_LOCKService Bus receiver, on close, dropped messagesthat had been prefetched into the client buffer or were still in flight on
the wire (leftover link credit), leaving them locked at the broker until lock
expiry — delaying redelivery and inflating
delivery_count(risking prematuredead-lettering under repeated receiver churn).
Repro
The reporter's scenario, reproduced against a live namespace:
prefetch_count=0, receiver recreated each loop;receive_messagesreturns partialbatches, leaving outstanding link credit;
Bug metric: messages redelivered with
delivery_count > 0— i.e. leftlocked at close and redelivered only after the lock expired.
Why the test queue uses
LockDuration = 10s: this is a test-environmentchoice, not part of the fix. The default lock duration is up to 5 minutes, so a
lock-expiry redelivery would take minutes to observe. A short 10s lock makes the
bug surface within seconds. The fix itself uses no
sleepand nolock-duration-dependent timing — the reporter's
asyncio.sleep(10)workaroundis removed entirely.
Fix (transport layer — vendored
_pyamqpuntouched)The
_pyamqppackage is vendored and shared withazure-eventhub, so the fixlives in the Service Bus transport layer instead of the vendored client:
PyamqpTransport.drain_and_release_messages(and the async twin) sends adrain flow (
flow(link_credit=0, drain=True)), pumps the connection untilquiescent (bounded by
RECEIVE_LINK_DRAIN_TIMEOUT = 5s, which exits early onquiescence — not a sleep), then releases every buffered/in-flight message with
the
releaseddisposition. No-op for the deprecateduamqptransport.ServiceBusReceiver._close_handlercalls it, gated to non-sessionPEEK_LOCK receivers (RECEIVE_AND_DELETE / Event Hubs use
Firstmode wherethe drain is a no-op). This is a superset of .NET's drain-on-close: like
.NET it drains in-flight transfers, and additionally releases the already
buffered prefetch so nothing is left locked.
released(notmodified/abandon) means the broker redelivers immediatelywithout incrementing
delivery_count.Tested after the fix
Unit (
tests/unittests/test_receiver_drain_on_close.py, 19 tests):transport drain/release mechanics (drain flow sent, in-flight pulled in,
released; skipped on no-credit / closed / null link; drain-failure still
releases; timeout-bounded) + receiver gating (PEEK_LOCK-non-session drains;
RECEIVE_AND_DELETE and session skip) — sync and async. Verified all behavior
tests fail when the fix is disabled. Full offline unit-test suite passes;
pylint/mypy clean on changed files.
Live A/B on a real namespace (queue LockDuration 10s), reporter's scenario,
no
sleepworkaround, ~60–90s per run — lock-expiry redeliveries(
delivery_count > 0) per run:POST_DELAYamplifier)Skipped paths: RECEIVE_AND_DELETE and session receivers receive all messages
and close cleanly (drain correctly skipped).
Are there any breaking changes?
No. Behavior only improves (locks released sooner); no public API change.