Skip to content

Commit 8f8855b

Browse files
authored
[Service Bus] Drain receive link and release messages on receiver close (#42917)
When receive_messages grants AMQP link credit, the broker may transfer more messages than the application consumes before the call returns. Previously, closing the receiver cleared the client buffer without settling it and never drained the link, leaving those messages locked at the broker until lock expiry (delaying redelivery and inflating the delivery count). This adds a transport-layer drain-and-release on close (the vendored _pyamqp package is left untouched): - PyamqpTransport.drain_and_release_messages (and the async twin) drains the link via flow(link_credit=0, drain=True), pumps the connection until quiescent (bounded by a timeout), then releases every buffered message with the released disposition. - ServiceBusReceiver._close_handler calls it, gated to non-session PEEK_LOCK receivers. The non-session gate matches the .NET SDK; the PEEK_LOCK gate is a Python refinement (a pre-settled RECEIVE_AND_DELETE delivery cannot be released-settled). Adds unit tests covering the transport drain/release paths and the receiver close gating (sync + async). Fixes #42917
1 parent 777741b commit 8f8855b

10 files changed

Lines changed: 508 additions & 1 deletion

File tree

sdk/servicebus/azure-servicebus/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- Read `com.microsoft:max-message-batch-size` vendor property from the AMQP sender link to correctly limit batch size on Premium large-message entities, where `max-message-size` can be up to 100 MB but the batch limit is 1 MB.
1313
- Fixed a bug where sending a batched or multi-message payload with `uamqp_transport=True` raised `TypeError: 'BatchMessage' object is not subscriptable` (and a masked `AttributeError` on the list path) when the first message carried a `message_id`, `session_id`, or `partition_key`. The batch envelope properties are now set through the transport-appropriate code path. (regression from [#42598](https://github.com/Azure/azure-sdk-for-python/pull/42598))
1414
- Fixed a bug where the async receiver factory methods on `azure.servicebus.aio.ServiceBusClient` (`get_queue_receiver`, `get_subscription_receiver`) and the async `ServiceBusReceiver` annotated the `auto_lock_renewer` keyword with the synchronous `AutoLockRenewer`, causing static type checkers to reject the documented `azure.servicebus.aio.AutoLockRenewer` usage. The annotation now references the async `AutoLockRenewer`, matching the docstrings and runtime behavior. ([#47948](https://github.com/Azure/azure-sdk-for-python/issues/47948))
15+
- Fixed a bug where closing a `PEEK_LOCK` receiver did not release messages that had been prefetched into the client buffer or were still in flight, so they remained locked at the broker until lock expiry — delaying their redelivery and inflating their delivery count. On close, a non-session `PEEK_LOCK` receiver now drains the link (stopping the broker and flushing in-flight transfers) and releases the buffered messages (`released` disposition), so the broker can redeliver them immediately without incrementing the delivery count. ([#42917](https://github.com/Azure/azure-sdk-for-python/issues/42917))
1516

1617
## 7.14.3 (2025-11-11)
1718

sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,17 @@ def _renew_locks(self, *lock_tokens: str, **kwargs: Any) -> Any:
538538

539539
def _close_handler(self):
540540
self._message_iter = None
541+
if (
542+
self._handler
543+
and self._receive_mode == ServiceBusReceiveMode.PEEK_LOCK
544+
and not self._session
545+
):
546+
# Drain the link and release buffered/in-flight messages so they are not
547+
# left locked at the broker until lock expiry (delaying redelivery,
548+
# inflating delivery count). Non-session gate matches .NET; the PEEK_LOCK
549+
# gate is Python-specific (a pre-settled RECEIVE_AND_DELETE delivery
550+
# cannot be released-settled).
551+
self._amqp_transport.drain_and_release_messages(self._handler)
541552
super(ServiceBusReceiver, self)._close_handler()
542553

543554
@property

sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_base.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,14 @@ def reset_link_credit(handler, link_credit):
302302
:param int link_credit: The link credit.
303303
"""
304304

305+
@staticmethod
306+
@abstractmethod
307+
def drain_and_release_messages(handler):
308+
"""
309+
Drain the receive link and release buffered/in-flight messages on close.
310+
:param ~uamqp.ReceiveClient or ~pyamqp.ReceiveClient handler: The handler.
311+
"""
312+
305313
@staticmethod
306314
@abstractmethod
307315
def settle_message_via_receiver_link(

sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from datetime import timezone
1212
from typing import Optional, Tuple, cast, List, TYPE_CHECKING, Any, Callable, Dict, Union, Iterator, Type
1313
import logging
14+
import queue
1415

1516
from .._pyamqp import (
1617
utils,
@@ -157,6 +158,10 @@ def is_retryable(self, error):
157158
ERROR_CODE_TIMEOUT: OperationTimeoutError,
158159
}
159160

161+
# Safety cap (seconds) for draining a receive link on close; the drain loop exits
162+
# early on quiescence, so a normal close returns well before this.
163+
RECEIVE_LINK_DRAIN_TIMEOUT = 5
164+
160165

161166
class PyamqpTransport(AmqpTransport): # pylint: disable=too-many-public-methods
162167
"""
@@ -799,6 +804,60 @@ def reset_link_credit(handler: "ReceiveClient", link_credit: int) -> None:
799804
"""
800805
handler._link.flow(link_credit=link_credit) # pylint: disable=protected-access
801806

807+
@staticmethod
808+
def drain_and_release_messages(handler: "ReceiveClient") -> None:
809+
"""
810+
Drain the receive link and release buffered/in-flight messages on close, so
811+
they are not left locked at the broker until lock expiry. Intended for
812+
non-session PEEK_LOCK receivers only (gated by the caller).
813+
:param ReceiveClient handler: Client whose receive link to drain.
814+
:rtype: None
815+
"""
816+
# pylint: disable=protected-access
817+
link = handler._link
818+
if link is None or link._is_closed:
819+
return # cannot drain or settle through a closed/absent link
820+
if link.current_link_credit > 0:
821+
# Drain in-flight transfers into the buffer. Own try so a drain
822+
# failure still falls through to the release below.
823+
try:
824+
outstanding = link.current_link_credit
825+
link.flow(link_credit=0, drain=True)
826+
deadline = time.time() + RECEIVE_LINK_DRAIN_TIMEOUT
827+
idle_cycles = 0
828+
while True:
829+
remaining = deadline - time.time()
830+
if remaining <= 0:
831+
break
832+
before = handler._received_messages.qsize()
833+
# listen() directly, not do_work() (which re-issues credit at 0 and
834+
# undoes the drain). batch=outstanding assembles multi-frame transfers
835+
# in one cycle; cap each read by the remaining budget so close stays
836+
# bounded. pyamqp drops the drain echo, so stop after 2 idle cycles.
837+
wait = remaining if handler._socket_timeout is None else min(handler._socket_timeout, remaining)
838+
handler._connection.listen(wait=wait, batch=outstanding)
839+
if handler._received_messages.qsize() == before:
840+
idle_cycles += 1
841+
if idle_cycles >= 2:
842+
break
843+
else:
844+
idle_cycles = 0
845+
except Exception: # pylint: disable=broad-except
846+
_LOGGER.debug("Draining the receive link on close failed.", exc_info=True)
847+
# Release buffered deliveries (incl. credit==0 prefetch) so they are not
848+
# left locked until lock expiry. Per-message try so one bad tag doesn't
849+
# strand the rest; get_nowait handles the empty()/get race.
850+
while True:
851+
try:
852+
frame, _ = handler._received_messages.get_nowait()
853+
except queue.Empty:
854+
break
855+
try:
856+
handler.settle_messages(frame[1], frame[2], "released")
857+
handler._received_messages.task_done()
858+
except Exception: # pylint: disable=broad-except
859+
_LOGGER.debug("Releasing a buffered message on close failed.", exc_info=True)
860+
802861
@staticmethod
803862
def settle_message_via_receiver_link(
804863
handler: ReceiveClient,

sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_uamqp_transport.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,15 @@ def reset_link_credit(handler: "ReceiveClient", link_credit: int) -> None:
848848
"""
849849
handler.message_handler.reset_link_credit(link_credit)
850850

851+
@staticmethod
852+
def drain_and_release_messages(handler: "ReceiveClient") -> None:
853+
"""
854+
No-op for uamqp: drain-on-close is only implemented for the pyamqp
855+
transport (the default). uamqp is deprecated.
856+
:param ~uamqp.ReceiveClient handler: The handler.
857+
:rtype: None
858+
"""
859+
851860
# Executes message settlement, implementation is in settle_message_via_receiver_link_impl
852861
# May be able to remove and just call methods in private method.
853862
@staticmethod

sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,17 @@ async def _renew_locks(self, *lock_tokens: str, timeout: Optional[float] = None)
527527

528528
async def _close_handler(self):
529529
self._message_iter = None
530+
if (
531+
self._handler
532+
and self._receive_mode == ServiceBusReceiveMode.PEEK_LOCK
533+
and not self._session
534+
):
535+
# Drain the link and release buffered/in-flight messages so they are not
536+
# left locked at the broker until lock expiry (delaying redelivery,
537+
# inflating delivery count). Non-session gate matches .NET; the PEEK_LOCK
538+
# gate is Python-specific (a pre-settled RECEIVE_AND_DELETE delivery
539+
# cannot be released-settled).
540+
await self._amqp_transport.drain_and_release_messages_async(self._handler)
530541
await super(ServiceBusReceiver, self)._close_handler()
531542

532543
@property

sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_base_async.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,16 @@ async def reset_link_credit_async(handler, link_credit):
244244
:rtype: None
245245
"""
246246

247+
@staticmethod
248+
@abstractmethod
249+
async def drain_and_release_messages_async(handler):
250+
"""
251+
Drain the receive link and release buffered/in-flight messages on close.
252+
:param ~uamqp.ReceiveClientAsync
253+
or ~pyamqp.aio.ReceiveClientAsync handler: Client whose receive link to drain.
254+
:rtype: None
255+
"""
256+
247257
@staticmethod
248258
@abstractmethod
249259
async def settle_message_via_receiver_link_async(

sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_pyamqp_transport_async.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import time
1010
import math
1111
import random
12+
import logging
13+
import queue
1214

1315
from ..._pyamqp import constants
1416
from ..._pyamqp.message import BatchMessage
@@ -42,7 +44,7 @@
4244
OPERATION_TIMEOUT,
4345
NEXT_AVAILABLE_SESSION,
4446
)
45-
from ..._transport._pyamqp_transport import PyamqpTransport
47+
from ..._transport._pyamqp_transport import PyamqpTransport, RECEIVE_LINK_DRAIN_TIMEOUT
4648
from ...exceptions import (
4749
OperationTimeoutError,
4850
ServiceBusConnectionError,
@@ -59,6 +61,9 @@
5961
from ..._pyamqp.aio._client_async import AMQPClientAsync
6062

6163

64+
_LOGGER = logging.getLogger(__name__)
65+
66+
6267
class PyamqpTransportAsync(PyamqpTransport, AmqpTransportAsync):
6368
"""
6469
Class which defines pyamqp-based methods used by the sender and receiver.
@@ -301,6 +306,61 @@ async def reset_link_credit_async(handler: "ReceiveClientAsync", link_credit: in
301306
"""
302307
await handler._link.flow(link_credit=link_credit) # pylint: disable=protected-access
303308

309+
@staticmethod
310+
async def drain_and_release_messages_async(handler: "ReceiveClientAsync") -> None:
311+
"""
312+
Drain the receive link and release buffered/in-flight messages on close, so
313+
they are not left locked at the broker until lock expiry. Intended for
314+
non-session PEEK_LOCK receivers only (gated by the caller).
315+
:param ReceiveClientAsync handler: Client whose receive link to drain.
316+
:rtype: None
317+
"""
318+
# pylint: disable=protected-access
319+
link = handler._link
320+
if link is None or link._is_closed:
321+
return # cannot drain or settle through a closed/absent link
322+
if link.current_link_credit > 0:
323+
# Drain in-flight transfers into the buffer. Own try so a drain
324+
# failure still falls through to the release below.
325+
try:
326+
outstanding = link.current_link_credit
327+
await link.flow(link_credit=0, drain=True)
328+
deadline = time.time() + RECEIVE_LINK_DRAIN_TIMEOUT
329+
idle_cycles = 0
330+
while True:
331+
remaining = deadline - time.time()
332+
if remaining <= 0:
333+
break
334+
before = handler._received_messages.qsize()
335+
# listen() directly, not do_work_async() (which re-issues credit at
336+
# 0 and undoes the drain). batch=outstanding assembles multi-frame
337+
# transfers in one cycle; cap each read by the remaining budget so
338+
# close stays bounded. pyamqp drops the drain echo, so stop after 2
339+
# idle cycles.
340+
wait = remaining if handler._socket_timeout is None else min(handler._socket_timeout, remaining)
341+
await handler._connection.listen(wait=wait, batch=outstanding)
342+
if handler._received_messages.qsize() == before:
343+
idle_cycles += 1
344+
if idle_cycles >= 2:
345+
break
346+
else:
347+
idle_cycles = 0
348+
except Exception: # pylint: disable=broad-except
349+
_LOGGER.debug("Draining the receive link on close failed.", exc_info=True)
350+
# Release buffered deliveries (incl. credit==0 prefetch) so they are not
351+
# left locked until lock expiry. Per-message try so one bad tag doesn't
352+
# strand the rest; get_nowait handles the empty()/get race.
353+
while True:
354+
try:
355+
frame, _ = handler._received_messages.get_nowait()
356+
except queue.Empty:
357+
break
358+
try:
359+
await handler.settle_messages_async(frame[1], frame[2], "released")
360+
handler._received_messages.task_done()
361+
except Exception: # pylint: disable=broad-except
362+
_LOGGER.debug("Releasing a buffered message on close failed.", exc_info=True)
363+
304364
@staticmethod
305365
async def settle_message_via_receiver_link_async(
306366
handler: "ReceiveClientAsync",

sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_uamqp_transport_async.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,15 @@ async def reset_link_credit_async(handler: "ReceiveClientAsync", link_credit: in
248248
"""
249249
await handler.message_handler.reset_link_credit_async(link_credit)
250250

251+
@staticmethod
252+
async def drain_and_release_messages_async(handler: "ReceiveClientAsync") -> None:
253+
"""
254+
No-op for uamqp: drain-on-close is only implemented for the pyamqp
255+
transport (the default). uamqp is deprecated.
256+
:param ReceiveClientAsync handler: The handler.
257+
:rtype: None
258+
"""
259+
251260
@staticmethod
252261
async def settle_message_via_receiver_link_async(
253262
handler: "ReceiveClientAsync",

0 commit comments

Comments
 (0)