Skip to content

[Service Bus] Drain receive link and release messages on receiver close (#42917)#47999

Open
pranz1996 wants to merge 5 commits into
Azure:mainfrom
pranz1996:fix/receiver-drain-on-close-42917
Open

[Service Bus] Drain receive link and release messages on receiver close (#42917)#47999
pranz1996 wants to merge 5 commits into
Azure:mainfrom
pranz1996:fix/receiver-drain-on-close-42917

Conversation

@pranz1996

@pranz1996 pranz1996 commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #42917. A PEEK_LOCK Service Bus receiver, on close, dropped messages
that 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 premature
dead-lettering under repeated receiver churn).

Repro

The reporter's scenario, reproduced against a live namespace:

  • async receiver, prefetch_count=0, receiver recreated each loop;
  • a sender trickles messages (~2 msg/s) so receive_messages returns partial
    batches, leaving outstanding link credit;
  • each received message is completed, then the receiver is closed — repeat.

Bug metric: messages redelivered with delivery_count > 0 — i.e. left
locked at close and redelivered only after the lock expired.

Why the test queue uses LockDuration = 10s: this is a test-environment
choice, 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 sleep and no
lock-duration-dependent timing
— the reporter's asyncio.sleep(10) workaround
is removed entirely.

Fix (transport layer — vendored _pyamqp untouched)

The _pyamqp package is vendored and shared with azure-eventhub, so the fix
lives in the Service Bus transport layer instead of the vendored client:

  • PyamqpTransport.drain_receive_link_and_release_messages (and the async
    twin) sends a drain flow (flow(link_credit=0, drain=True)), pumps the
    connection until quiescent (bounded by RECEIVE_LINK_DRAIN_TIMEOUT = 5s, which
    exits early on quiescence — not a sleep), then releases every buffered/in-flight
    message 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; Event Hubs /
    RECEIVE_AND_DELETE use First mode where the drain is a no-op).

released (not modified/abandon) means the broker redelivers immediately
without incrementing delivery_count.

Tested after the fix

Unit (tests/unittests/test_receiver_drain_on_close.py, 18 tests):
transport drain/release mechanics (drain flow sent, in-flight pulled in,
released; skipped on no-credit / closed / null link; failure-guarded; 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 sleep workaround, ~60–90s per run — lock-expiry redeliveries
(delivery_count > 0) per run:

Build Redeliveries per run
Unpatched (7.14.2) 5, 9
Release-only (interim) 4, 4
Drain + release (this PR) 0, 0, 0 (+ 0 under a POST_DELAY amplifier)

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.

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

Copilot AI left a comment

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.

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.

Comment thread sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/client.py Outdated
Comment thread sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/aio/_client_async.py Outdated
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
@pranz1996 pranz1996 changed the title [Service Bus] Release buffered messages on receiver close (#42917) [Service Bus] Drain receive link and release messages on receiver close (#42917) Jul 10, 2026
@pranz1996 pranz1996 requested a review from Copilot July 10, 2026 21:54

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Comment thread sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py Outdated
Comment thread sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py Outdated
- 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

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Comment thread sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py Outdated
)

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
@pranz1996 pranz1996 marked this pull request as ready for review July 10, 2026 22:53
@pranz1996

Copy link
Copy Markdown
Member Author

@microsoft-github-policy-service agree company="Microsoft"

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Service bus receiver drops messages on shutdown when using receive_batch

2 participants