From b43b59ac38e1e9a47b5106a35d102e20dfea73ea Mon Sep 17 00:00:00 2001 From: Eldert Grootenboer Date: Tue, 1 Sep 2026 17:53:53 -0700 Subject: [PATCH 1/8] [Service Bus] Add batch delete and purge APIs --- sdk/servicebus/azure-servicebus/CHANGELOG.md | 1 + sdk/servicebus/azure-servicebus/api.md | 1592 +++++++++-------- .../azure-servicebus/api.metadata.yml | 2 +- .../azure/servicebus/__init__.py | 3 + .../azure/servicebus/_base_handler.py | 19 +- .../azure/servicebus/_common/constants.py | 3 + .../azure/servicebus/_common/mgmt_handlers.py | 22 + .../azure/servicebus/_common/utils.py | 9 + .../azure/servicebus/_models.py | 30 + .../azure/servicebus/_servicebus_receiver.py | 299 +++- .../servicebus/aio/_base_handler_async.py | 19 +- .../aio/_servicebus_receiver_async.py | 327 +++- .../sample_code_servicebus_async.py | 157 +- .../sync_samples/sample_code_servicebus.py | 161 +- .../tests/unittests/test_batch_delete.py | 575 ++++++ 15 files changed, 2308 insertions(+), 911 deletions(-) create mode 100644 sdk/servicebus/azure-servicebus/azure/servicebus/_models.py create mode 100644 sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py diff --git a/sdk/servicebus/azure-servicebus/CHANGELOG.md b/sdk/servicebus/azure-servicebus/CHANGELOG.md index 752f6724eb50..ef1e0fbf258c 100644 --- a/sdk/servicebus/azure-servicebus/CHANGELOG.md +++ b/sdk/servicebus/azure-servicebus/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features Added +- Added sync and async `ServiceBusReceiver.delete_messages()` and `purge_messages()` methods. Basic and Standard support up to 500 messages per request, Premium supports up to 4,000, and purge handles smaller batches caused by large messages. - Added `ServiceBusReceivedMessage.from_bytes()` classmethod to construct a `ServiceBusReceivedMessage` from raw AMQP payload bytes without requiring the deprecated `uamqp` library. ([#43979](https://github.com/Azure/azure-sdk-for-python/issues/43979)) - Added `ServiceBusClient.list_queue_sessions()` and `ServiceBusClient.list_subscription_sessions()` (sync and async) to list session IDs for entities with active messages, with optional filtering by session-state update timestamp. The methods return an `ItemPaged[str]` (`AsyncItemPaged[str]` on the async client) so callers can iterate every session transparently or page with `by_page()`. Implements the `com.microsoft:get-message-sessions` management operation. ([#46575](https://github.com/Azure/azure-sdk-for-python/pull/46575)) - Added `sql_filter_count` and `correlation_filter_count` properties to `TopicRuntimeProperties`, exposing the total number of SQL filters and correlation filters across all of a topic's subscriptions. diff --git a/sdk/servicebus/azure-servicebus/api.md b/sdk/servicebus/azure-servicebus/api.md index f3562de76448..3b77dca0527a 100644 --- a/sdk/servicebus/azure-servicebus/api.md +++ b/sdk/servicebus/azure-servicebus/api.md @@ -4,135 +4,149 @@ namespace azure.servicebus def azure.servicebus.parse_connection_string(conn_str: str) -> ServiceBusConnectionStringProperties: ... - class azure.servicebus.AutoLockRenewer: implements ContextManager + class azure.servicebus.AutoLockRenewer: implements ContextManager def __init__( - self, - max_lock_renewal_duration: float = 300, - on_lock_renew_failure: Optional[LockRenewFailureCallback] = None, - executor: Optional[ThreadPoolExecutor] = None, + self, + max_lock_renewal_duration: float = 300, + on_lock_renew_failure: Optional[LockRenewFailureCallback] = None, + executor: Optional[ThreadPoolExecutor] = None, max_workers: Optional[int] = None ) -> None: ... def close(self, wait: bool = True) -> None: ... def register( - self, - receiver: ServiceBusReceiver, - renewable: Union[ServiceBusReceivedMessage, ServiceBusSession], - max_lock_renewal_duration: Optional[float] = None, + self, + receiver: ServiceBusReceiver, + renewable: Union[ServiceBusReceivedMessage, ServiceBusSession], + max_lock_renewal_duration: Optional[float] = None, on_lock_renew_failure: Optional[LockRenewFailureCallback] = None ) -> None: ... - class azure.servicebus.ServiceBusClient: implements ContextManager + class azure.servicebus.DeleteMessagesResult: + property deleted_message_count: int # Read-only + deleted_message_count: int + + def __init__(self, deleted_message_count: int) -> None: ... + + + class azure.servicebus.PurgeMessagesResult(DeleteMessagesResult): + property deleted_message_count: int # Read-only + deleted_message_count: int + + def __init__(self, deleted_message_count: int) -> None: ... + + + class azure.servicebus.ServiceBusClient: implements ContextManager fully_qualified_namespace: str def __init__( - self, - fully_qualified_namespace: str, - credential: Union[TokenCredential, AzureSasCredential, AzureNamedKeyCredential], - *, - connection_verify: Optional[str] = ..., - custom_endpoint_address: Optional[str] = ..., - http_proxy: Optional[Dict] = ..., - logging_enable: Optional[bool] = ..., - retry_backoff_factor: float = 0.8, - retry_backoff_max: float = 120, - retry_mode: str = "exponential", - retry_total: int = 3, - ssl_context: Union[SSLContext, None] = ..., - transport_type: TransportType = ..., - uamqp_transport: bool = ..., - user_agent: Optional[str] = ..., + self, + fully_qualified_namespace: str, + credential: Union[TokenCredential, AzureSasCredential, AzureNamedKeyCredential], + *, + connection_verify: Optional[str] = ..., + custom_endpoint_address: Optional[str] = ..., + http_proxy: Optional[Dict] = ..., + logging_enable: Optional[bool] = ..., + retry_backoff_factor: float = 0.8, + retry_backoff_max: float = 120, + retry_mode: str = "exponential", + retry_total: int = 3, + ssl_context: Union[SSLContext, None] = ..., + transport_type: TransportType = ..., + uamqp_transport: bool = ..., + user_agent: Optional[str] = ..., **kwargs: Any ) -> None: ... @classmethod def from_connection_string( - cls, - conn_str: str, - *, - connection_verify: Optional[str] = ..., - custom_endpoint_address: Optional[str] = ..., - http_proxy: Optional[Dict] = ..., - logging_enable: Optional[bool] = ..., - retry_backoff_factor: float = 0.8, - retry_backoff_max: float = 120, - retry_mode: str = "exponential", - retry_total: int = 3, - ssl_context: Union[SSLContext, None] = ..., - transport_type: TransportType = ..., - uamqp_transport: bool = ..., - user_agent: Optional[str] = ..., + cls, + conn_str: str, + *, + connection_verify: Optional[str] = ..., + custom_endpoint_address: Optional[str] = ..., + http_proxy: Optional[Dict] = ..., + logging_enable: Optional[bool] = ..., + retry_backoff_factor: float = 0.8, + retry_backoff_max: float = 120, + retry_mode: str = "exponential", + retry_total: int = 3, + ssl_context: Union[SSLContext, None] = ..., + transport_type: TransportType = ..., + uamqp_transport: bool = ..., + user_agent: Optional[str] = ..., **kwargs: Any ) -> ServiceBusClient: ... def close(self) -> None: ... def get_queue_receiver( - self, - queue_name: str, - *, - auto_lock_renewer: Optional[AutoLockRenewer] = ..., - client_identifier: Optional[str] = ..., - max_wait_time: Optional[float] = ..., - prefetch_count: int = 0, - receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, - session_id: Optional[Union[str, NextAvailableSessionType]] = ..., - socket_timeout: Optional[float] = ..., - sub_queue: Optional[Union[ServiceBusSubQueue, str]] = ..., + self, + queue_name: str, + *, + auto_lock_renewer: Optional[AutoLockRenewer] = ..., + client_identifier: Optional[str] = ..., + max_wait_time: Optional[float] = ..., + prefetch_count: int = 0, + receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, + session_id: Optional[Union[str, NextAvailableSessionType]] = ..., + socket_timeout: Optional[float] = ..., + sub_queue: Optional[Union[ServiceBusSubQueue, str]] = ..., **kwargs: Any ) -> ServiceBusReceiver: ... def get_queue_sender( - self, - queue_name: str, - *, - client_identifier: Optional[str] = ..., - socket_timeout: Optional[float] = ..., + self, + queue_name: str, + *, + client_identifier: Optional[str] = ..., + socket_timeout: Optional[float] = ..., **kwargs: Any ) -> ServiceBusSender: ... def get_subscription_receiver( - self, - topic_name: str, - subscription_name: str, - *, - auto_lock_renewer: Optional[AutoLockRenewer] = ..., - client_identifier: Optional[str] = ..., - max_wait_time: Optional[float] = ..., - prefetch_count: int = 0, - receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, - session_id: Optional[Union[str, NextAvailableSessionType]] = ..., - socket_timeout: Optional[float] = ..., - sub_queue: Optional[Union[ServiceBusSubQueue, str]] = ..., + self, + topic_name: str, + subscription_name: str, + *, + auto_lock_renewer: Optional[AutoLockRenewer] = ..., + client_identifier: Optional[str] = ..., + max_wait_time: Optional[float] = ..., + prefetch_count: int = 0, + receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, + session_id: Optional[Union[str, NextAvailableSessionType]] = ..., + socket_timeout: Optional[float] = ..., + sub_queue: Optional[Union[ServiceBusSubQueue, str]] = ..., **kwargs: Any ) -> ServiceBusReceiver: ... def get_topic_sender( - self, - topic_name: str, - *, - client_identifier: Optional[str] = ..., - socket_timeout: Optional[float] = ..., + self, + topic_name: str, + *, + client_identifier: Optional[str] = ..., + socket_timeout: Optional[float] = ..., **kwargs: Any ) -> ServiceBusSender: ... def list_queue_sessions( - self, - queue_name: str, - *, - state_updated_after: Optional[datetime] = ..., + self, + queue_name: str, + *, + state_updated_after: Optional[datetime] = ..., timeout: Optional[float] = ... ) -> ItemPaged[str]: ... def list_subscription_sessions( - self, - topic_name: str, - subscription_name: str, - *, - state_updated_after: Optional[datetime] = ..., + self, + topic_name: str, + subscription_name: str, + *, + state_updated_after: Optional[datetime] = ..., timeout: Optional[float] = ... ) -> ItemPaged[str]: ... @@ -154,13 +168,13 @@ namespace azure.servicebus def __getitem__(self, key: str) -> Any: ... def __init__( - self, - *, - endpoint: str, - entity_path: Optional[str] = ..., - fully_qualified_namespace: str, - shared_access_key: Optional[str] = ..., - shared_access_key_name: Optional[str] = ..., + self, + *, + endpoint: str, + entity_path: Optional[str] = ..., + fully_qualified_namespace: str, + shared_access_key: Optional[str] = ..., + shared_access_key_name: Optional[str] = ..., shared_access_signature: Optional[str] = ... ): ... @@ -171,16 +185,16 @@ namespace azure.servicebus def __repr__(self) -> str: ... def __setitem__( - self, - key: str, + self, + key: str, item: Any ) -> None: ... def __str__(self) -> str: ... def get( - self, - key: str, + self, + key: str, default: Optional[Any] = None ) -> Any: ... @@ -191,8 +205,8 @@ namespace azure.servicebus def keys(self) -> List[str]: ... def update( - self, - *args: Any, + self, + *args: Any, **kwargs: Any ) -> None: ... @@ -218,21 +232,21 @@ namespace azure.servicebus property to: Optional[str] def __init__( - self, - body: Optional[Union[str, bytes]], - *, - application_properties: Optional[Dict[Union[str, bytes], PrimitiveTypes]] = ..., - content_type: Optional[str] = ..., - correlation_id: Optional[str] = ..., - message_id: Optional[str] = ..., - partition_key: Optional[str] = ..., - reply_to: Optional[str] = ..., - reply_to_session_id: Optional[str] = ..., - scheduled_enqueue_time_utc: Optional[datetime] = ..., - session_id: Optional[str] = ..., - subject: Optional[str] = ..., - time_to_live: Optional[timedelta] = ..., - to: Optional[str] = ..., + self, + body: Optional[Union[str, bytes]], + *, + application_properties: Optional[Dict[Union[str, bytes], PrimitiveTypes]] = ..., + content_type: Optional[str] = ..., + correlation_id: Optional[str] = ..., + message_id: Optional[str] = ..., + partition_key: Optional[str] = ..., + reply_to: Optional[str] = ..., + reply_to_session_id: Optional[str] = ..., + scheduled_enqueue_time_utc: Optional[datetime] = ..., + session_id: Optional[str] = ..., + subject: Optional[str] = ..., + time_to_live: Optional[timedelta] = ..., + to: Optional[str] = ..., **kwargs: Any ) -> None: ... @@ -247,8 +261,8 @@ namespace azure.servicebus property size_in_bytes: int # Read-only def __init__( - self, - max_size_in_bytes: Optional[int] = None, + self, + max_size_in_bytes: Optional[int] = None, **kwargs: Any ) -> None: ... @@ -303,10 +317,10 @@ namespace azure.servicebus def __getstate__(self) -> Dict[str, Any]: ... def __init__( - self, - message: Union[Message, pyamqp_Message], - receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, - frame: Optional[TransferFrame] = None, + self, + message: Union[Message, pyamqp_Message], + receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, + frame: Optional[TransferFrame] = None, **kwargs: Any ) -> None: ... @@ -320,30 +334,30 @@ namespace azure.servicebus def from_bytes(cls, message: bytes) -> ServiceBusReceivedMessage: ... - class azure.servicebus.ServiceBusReceiver(BaseHandler, ReceiverMixin): implements ContextManager , Iterator + class azure.servicebus.ServiceBusReceiver(BaseHandler, ReceiverMixin): implements ContextManager , Iterator property client_identifier: str # Read-only property session: ServiceBusSession # Read-only entity_path: str fully_qualified_namespace: str def __init__( - self, - fully_qualified_namespace: str, - credential: Union[TokenCredential, AzureSasCredential, AzureNamedKeyCredential], - *, - auto_lock_renewer: Optional[AutoLockRenewer] = ..., - client_identifier: Optional[str] = ..., - http_proxy: Optional[Dict] = ..., - logging_enable: Optional[bool] = ..., - max_wait_time: Optional[float] = ..., - prefetch_count: int = 0, - queue_name: Optional[str] = ..., - receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, - socket_timeout: Optional[float] = ..., - subscription_name: Optional[str] = ..., - topic_name: Optional[str] = ..., - transport_type: TransportType = ..., - user_agent: Optional[str] = ..., + self, + fully_qualified_namespace: str, + credential: Union[TokenCredential, AzureSasCredential, AzureNamedKeyCredential], + *, + auto_lock_renewer: Optional[AutoLockRenewer] = ..., + client_identifier: Optional[str] = ..., + http_proxy: Optional[Dict] = ..., + logging_enable: Optional[bool] = ..., + max_wait_time: Optional[float] = ..., + prefetch_count: int = 0, + queue_name: Optional[str] = ..., + receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, + socket_timeout: Optional[float] = ..., + subscription_name: Optional[str] = ..., + topic_name: Optional[str] = ..., + transport_type: TransportType = ..., + user_agent: Optional[str] = ..., **kwargs: Any ) -> None: ... @@ -356,74 +370,90 @@ namespace azure.servicebus def complete_message(self, message: ServiceBusReceivedMessage) -> None: ... def dead_letter_message( - self, - message: ServiceBusReceivedMessage, - reason: Optional[str] = None, + self, + message: ServiceBusReceivedMessage, + reason: Optional[str] = None, error_description: Optional[str] = None ) -> None: ... def defer_message(self, message: ServiceBusReceivedMessage) -> None: ... + def delete_messages( + self, + message_count: int, + *, + before_enqueued_time: Optional[datetime] = ..., + timeout: Optional[float] = ... + ) -> DeleteMessagesResult: ... + def peek_messages( - self, - max_message_count: int = 1, - *, - sequence_number: int = 0, - timeout: Optional[float] = ..., + self, + max_message_count: int = 1, + *, + sequence_number: int = 0, + timeout: Optional[float] = ..., **kwargs: Any ) -> List[ServiceBusReceivedMessage]: ... + def purge_messages( + self, + *, + before_enqueued_time: Optional[datetime] = ..., + max_message_count_per_batch: int = 500, + timeout: Optional[float] = ... + ) -> PurgeMessagesResult: ... + def receive_deferred_messages( - self, - sequence_numbers: Union[int, List[int]], - *, - timeout: Optional[float] = ..., + self, + sequence_numbers: Union[int, List[int]], + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> List[ServiceBusReceivedMessage]: ... def receive_messages( - self, - max_message_count: Optional[int] = 1, + self, + max_message_count: Optional[int] = 1, max_wait_time: Optional[float] = None ) -> List[ServiceBusReceivedMessage]: ... def renew_message_lock( - self, - message: ServiceBusReceivedMessage, - *, - timeout: Optional[float] = ..., + self, + message: ServiceBusReceivedMessage, + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> datetime: ... - class azure.servicebus.ServiceBusSender(BaseHandler, SenderMixin): implements ContextManager + class azure.servicebus.ServiceBusSender(BaseHandler, SenderMixin): implements ContextManager property client_identifier: str # Read-only entity_name: str fully_qualified_namespace: str def __init__( - self, - fully_qualified_namespace: str, - credential: Union[TokenCredential, AzureSasCredential, AzureNamedKeyCredential], - *, - client_identifier: Optional[str] = ..., - http_proxy: Optional[Dict] = ..., - logging_enable: Optional[bool] = ..., - queue_name: Optional[str] = ..., - socket_timeout: Optional[float] = ..., - topic_name: Optional[str] = ..., - transport_type: TransportType = ..., - user_agent: Optional[str] = ..., + self, + fully_qualified_namespace: str, + credential: Union[TokenCredential, AzureSasCredential, AzureNamedKeyCredential], + *, + client_identifier: Optional[str] = ..., + http_proxy: Optional[Dict] = ..., + logging_enable: Optional[bool] = ..., + queue_name: Optional[str] = ..., + socket_timeout: Optional[float] = ..., + topic_name: Optional[str] = ..., + transport_type: TransportType = ..., + user_agent: Optional[str] = ..., **kwargs: Any ) -> None: ... def __str__(self) -> str: ... def cancel_scheduled_messages( - self, - sequence_numbers: Union[int, List[int]], - *, - timeout: Optional[float] = ..., + self, + sequence_numbers: Union[int, List[int]], + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> None: ... @@ -432,19 +462,19 @@ namespace azure.servicebus def create_message_batch(self, max_size_in_bytes: Optional[int] = None) -> ServiceBusMessageBatch: ... def schedule_messages( - self, - messages: MessageTypes, - schedule_time_utc: datetime, - *, - timeout: Optional[float] = ..., + self, + messages: MessageTypes, + schedule_time_utc: datetime, + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> List[int]: ... def send_messages( - self, - message: Union[MessageTypes, ServiceBusMessageBatch], - *, - timeout: Optional[float] = ..., + self, + message: Union[MessageTypes, ServiceBusMessageBatch], + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> None: ... @@ -455,30 +485,30 @@ namespace azure.servicebus auto_renew_error: Union[AutoLockRenewTimeout, AutoLockRenewFailed] def __init__( - self, - session_id: str, + self, + session_id: str, receiver: Union[ServiceBusReceiver, ServiceBusReceiverAsync] ) -> None: ... def get_state( - self, - *, - timeout: Optional[float] = ..., + self, + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> bytes: ... def renew_lock( - self, - *, - timeout: Optional[float] = ..., + self, + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> datetime: ... def set_state( - self, - state: Optional[Union[str, bytes, bytearray]], - *, - timeout: Optional[float] = ..., + self, + state: Optional[Union[str, bytes, bytearray]], + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> None: ... @@ -499,162 +529,162 @@ namespace azure.servicebus namespace azure.servicebus.aio - class azure.servicebus.aio.AutoLockRenewer: implements AsyncContextManager + class azure.servicebus.aio.AutoLockRenewer: implements AsyncContextManager def __init__( - self, - max_lock_renewal_duration: float = 300, - on_lock_renew_failure: Optional[AsyncLockRenewFailureCallback] = None, + self, + max_lock_renewal_duration: float = 300, + on_lock_renew_failure: Optional[AsyncLockRenewFailureCallback] = None, loop: Optional[AbstractEventLoop] = None ) -> None: ... async def close(self) -> None: ... def register( - self, - receiver: ServiceBusReceiver, - renewable: Union[ServiceBusReceivedMessage, ServiceBusSession], - max_lock_renewal_duration: Optional[float] = None, + self, + receiver: ServiceBusReceiver, + renewable: Union[ServiceBusReceivedMessage, ServiceBusSession], + max_lock_renewal_duration: Optional[float] = None, on_lock_renew_failure: Optional[AsyncLockRenewFailureCallback] = None ) -> None: ... - class azure.servicebus.aio.ServiceBusClient: implements AsyncContextManager + class azure.servicebus.aio.ServiceBusClient: implements AsyncContextManager fully_qualified_namespace: str def __init__( - self, - fully_qualified_namespace: str, - credential: Union[AsyncTokenCredential, AzureSasCredential, AzureNamedKeyCredential], - *, - connection_verify: Optional[str] = ..., - custom_endpoint_address: Optional[str] = ..., - http_proxy: Optional[Dict] = ..., - logging_enable: Optional[bool] = ..., - retry_backoff_factor: float = 0.8, - retry_backoff_max: float = 120, - retry_mode: str = "exponential", - retry_total: int = 3, - ssl_context: Union[SSLContext, None] = ..., - transport_type: TransportType = ..., - uamqp_transport: bool = ..., - user_agent: Optional[str] = ..., + self, + fully_qualified_namespace: str, + credential: Union[AsyncTokenCredential, AzureSasCredential, AzureNamedKeyCredential], + *, + connection_verify: Optional[str] = ..., + custom_endpoint_address: Optional[str] = ..., + http_proxy: Optional[Dict] = ..., + logging_enable: Optional[bool] = ..., + retry_backoff_factor: float = 0.8, + retry_backoff_max: float = 120, + retry_mode: str = "exponential", + retry_total: int = 3, + ssl_context: Union[SSLContext, None] = ..., + transport_type: TransportType = ..., + uamqp_transport: bool = ..., + user_agent: Optional[str] = ..., **kwargs: Any ) -> None: ... @classmethod def from_connection_string( - cls, - conn_str: str, - *, - connection_verify: Optional[str] = ..., - custom_endpoint_address: Optional[str] = ..., - http_proxy: Optional[Dict] = ..., - logging_enable: Optional[bool] = ..., - retry_backoff_factor: float = 0.8, - retry_backoff_max: float = 120, - retry_mode: str = "exponential", - retry_total: int = 3, - ssl_context: Union[SSLContext, None] = ..., - transport_type: TransportType = ..., - uamqp_transport: bool = ..., - user_agent: Optional[str] = ..., + cls, + conn_str: str, + *, + connection_verify: Optional[str] = ..., + custom_endpoint_address: Optional[str] = ..., + http_proxy: Optional[Dict] = ..., + logging_enable: Optional[bool] = ..., + retry_backoff_factor: float = 0.8, + retry_backoff_max: float = 120, + retry_mode: str = "exponential", + retry_total: int = 3, + ssl_context: Union[SSLContext, None] = ..., + transport_type: TransportType = ..., + uamqp_transport: bool = ..., + user_agent: Optional[str] = ..., **kwargs: Any ) -> ServiceBusClient: ... async def close(self) -> None: ... def get_queue_receiver( - self, - queue_name: str, - *, - auto_lock_renewer: Optional[AutoLockRenewer] = ..., - client_identifier: Optional[str] = ..., - max_wait_time: Optional[float] = ..., - prefetch_count: int = 0, - receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, - session_id: Optional[Union[str, NextAvailableSessionType]] = ..., - socket_timeout: Optional[float] = ..., - sub_queue: Optional[Union[ServiceBusSubQueue, str]] = ..., + self, + queue_name: str, + *, + auto_lock_renewer: Optional[AutoLockRenewer] = ..., + client_identifier: Optional[str] = ..., + max_wait_time: Optional[float] = ..., + prefetch_count: int = 0, + receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, + session_id: Optional[Union[str, NextAvailableSessionType]] = ..., + socket_timeout: Optional[float] = ..., + sub_queue: Optional[Union[ServiceBusSubQueue, str]] = ..., **kwargs: Any ) -> ServiceBusReceiver: ... def get_queue_sender( - self, - queue_name: str, - *, - client_identifier: Optional[str] = ..., - socket_timeout: Optional[float] = ..., + self, + queue_name: str, + *, + client_identifier: Optional[str] = ..., + socket_timeout: Optional[float] = ..., **kwargs: Any ) -> ServiceBusSender: ... def get_subscription_receiver( - self, - topic_name: str, - subscription_name: str, - *, - auto_lock_renewer: Optional[AutoLockRenewer] = ..., - client_identifier: Optional[str] = ..., - max_wait_time: Optional[float] = ..., - prefetch_count: int = 0, - receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, - session_id: Optional[Union[str, NextAvailableSessionType]] = ..., - socket_timeout: Optional[float] = ..., - sub_queue: Optional[Union[ServiceBusSubQueue, str]] = ..., + self, + topic_name: str, + subscription_name: str, + *, + auto_lock_renewer: Optional[AutoLockRenewer] = ..., + client_identifier: Optional[str] = ..., + max_wait_time: Optional[float] = ..., + prefetch_count: int = 0, + receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, + session_id: Optional[Union[str, NextAvailableSessionType]] = ..., + socket_timeout: Optional[float] = ..., + sub_queue: Optional[Union[ServiceBusSubQueue, str]] = ..., **kwargs: Any ) -> ServiceBusReceiver: ... def get_topic_sender( - self, - topic_name: str, - *, - client_identifier: Optional[str] = ..., - socket_timeout: Optional[float] = ..., + self, + topic_name: str, + *, + client_identifier: Optional[str] = ..., + socket_timeout: Optional[float] = ..., **kwargs: Any ) -> ServiceBusSender: ... def list_queue_sessions( - self, - queue_name: str, - *, - state_updated_after: Optional[datetime] = ..., + self, + queue_name: str, + *, + state_updated_after: Optional[datetime] = ..., timeout: Optional[float] = ... ) -> AsyncItemPaged[str]: ... def list_subscription_sessions( - self, - topic_name: str, - subscription_name: str, - *, - state_updated_after: Optional[datetime] = ..., + self, + topic_name: str, + subscription_name: str, + *, + state_updated_after: Optional[datetime] = ..., timeout: Optional[float] = ... ) -> AsyncItemPaged[str]: ... - class azure.servicebus.aio.ServiceBusReceiver(AsyncIterator, BaseHandler, ReceiverMixin): implements AsyncContextManager , AsyncIterable , AsyncIterator + class azure.servicebus.aio.ServiceBusReceiver(AsyncIterator, BaseHandler, ReceiverMixin): implements AsyncContextManager , AsyncIterable , AsyncIterator property client_identifier: str # Read-only property session: ServiceBusSession # Read-only entity_path: str fully_qualified_namespace: str def __init__( - self, - fully_qualified_namespace: str, - credential: Union[AsyncTokenCredential, AzureSasCredential, AzureNamedKeyCredential], - *, - auto_lock_renewer: Optional[AutoLockRenewer] = ..., - client_identifier: Optional[str] = ..., - http_proxy: Optional[Dict] = ..., - logging_enable: Optional[bool] = ..., - max_wait_time: Optional[float] = ..., - prefetch_count: int = 0, - queue_name: Optional[str] = ..., - receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, - socket_timeout: Optional[float] = ..., - subscription_name: Optional[str] = ..., - topic_name: Optional[str] = ..., - transport_type: TransportType = ..., - user_agent: Optional[str] = ..., + self, + fully_qualified_namespace: str, + credential: Union[AsyncTokenCredential, AzureSasCredential, AzureNamedKeyCredential], + *, + auto_lock_renewer: Optional[AutoLockRenewer] = ..., + client_identifier: Optional[str] = ..., + http_proxy: Optional[Dict] = ..., + logging_enable: Optional[bool] = ..., + max_wait_time: Optional[float] = ..., + prefetch_count: int = 0, + queue_name: Optional[str] = ..., + receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, + socket_timeout: Optional[float] = ..., + subscription_name: Optional[str] = ..., + topic_name: Optional[str] = ..., + transport_type: TransportType = ..., + user_agent: Optional[str] = ..., **kwargs: Any ) -> None: ... @@ -667,74 +697,90 @@ namespace azure.servicebus.aio async def complete_message(self, message: ServiceBusReceivedMessage) -> None: ... async def dead_letter_message( - self, - message: ServiceBusReceivedMessage, - reason: Optional[str] = None, + self, + message: ServiceBusReceivedMessage, + reason: Optional[str] = None, error_description: Optional[str] = None ) -> None: ... async def defer_message(self, message: ServiceBusReceivedMessage) -> None: ... + async def delete_messages( + self, + message_count: int, + *, + before_enqueued_time: Optional[datetime] = ..., + timeout: Optional[float] = ... + ) -> DeleteMessagesResult: ... + async def peek_messages( - self, - max_message_count: int = 1, - *, - sequence_number: int = 0, - timeout: Optional[float] = ..., + self, + max_message_count: int = 1, + *, + sequence_number: int = 0, + timeout: Optional[float] = ..., **kwargs: Any ) -> List[ServiceBusReceivedMessage]: ... + async def purge_messages( + self, + *, + before_enqueued_time: Optional[datetime] = ..., + max_message_count_per_batch: int = 500, + timeout: Optional[float] = ... + ) -> PurgeMessagesResult: ... + async def receive_deferred_messages( - self, - sequence_numbers: Union[int, List[int]], - *, - timeout: Optional[float] = ..., + self, + sequence_numbers: Union[int, List[int]], + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> List[ServiceBusReceivedMessage]: ... async def receive_messages( - self, - max_message_count: Optional[int] = 1, + self, + max_message_count: Optional[int] = 1, max_wait_time: Optional[float] = None ) -> List[ServiceBusReceivedMessage]: ... async def renew_message_lock( - self, - message: ServiceBusReceivedMessage, - *, - timeout: Optional[float] = ..., + self, + message: ServiceBusReceivedMessage, + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> datetime: ... - class azure.servicebus.aio.ServiceBusSender(BaseHandler, SenderMixin): implements AsyncContextManager + class azure.servicebus.aio.ServiceBusSender(BaseHandler, SenderMixin): implements AsyncContextManager property client_identifier: str # Read-only entity_name: str fully_qualified_namespace: str def __init__( - self, - fully_qualified_namespace: str, - credential: Union[AsyncTokenCredential, AzureSasCredential, AzureNamedKeyCredential], - *, - client_identifier: Optional[str] = ..., - http_proxy: Optional[Dict] = ..., - logging_enable: Optional[bool] = ..., - queue_name: Optional[str] = ..., - socket_timeout: Optional[float] = ..., - topic_name: Optional[str] = ..., - transport_type: TransportType = ..., - user_agent: Optional[str] = ..., + self, + fully_qualified_namespace: str, + credential: Union[AsyncTokenCredential, AzureSasCredential, AzureNamedKeyCredential], + *, + client_identifier: Optional[str] = ..., + http_proxy: Optional[Dict] = ..., + logging_enable: Optional[bool] = ..., + queue_name: Optional[str] = ..., + socket_timeout: Optional[float] = ..., + topic_name: Optional[str] = ..., + transport_type: TransportType = ..., + user_agent: Optional[str] = ..., **kwargs: Any ) -> None: ... def __str__(self) -> str: ... async def cancel_scheduled_messages( - self, - sequence_numbers: Union[int, List[int]], - *, - timeout: Optional[float] = ..., + self, + sequence_numbers: Union[int, List[int]], + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> None: ... @@ -743,19 +789,19 @@ namespace azure.servicebus.aio async def create_message_batch(self, max_size_in_bytes: Optional[int] = None) -> ServiceBusMessageBatch: ... async def schedule_messages( - self, - messages: MessageTypes, - schedule_time_utc: datetime, - *, - timeout: Optional[float] = ..., + self, + messages: MessageTypes, + schedule_time_utc: datetime, + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> List[int]: ... async def send_messages( - self, - message: Union[MessageTypes, ServiceBusMessageBatch], - *, - timeout: Optional[float] = ..., + self, + message: Union[MessageTypes, ServiceBusMessageBatch], + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> None: ... @@ -765,205 +811,205 @@ namespace azure.servicebus.aio property session_id: str # Read-only def __init__( - self, - session_id: str, + self, + session_id: str, receiver: Union[ServiceBusReceiver, ServiceBusReceiverAsync] ) -> None: ... async def get_state( - self, - *, - timeout: Optional[float] = ..., + self, + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> bytes: ... async def renew_lock( - self, - *, - timeout: Optional[float] = ..., + self, + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> datetime: ... async def set_state( - self, - state: Optional[Union[str, bytes, bytearray]], - *, - timeout: Optional[float] = ..., + self, + state: Optional[Union[str, bytes, bytearray]], + *, + timeout: Optional[float] = ..., **kwargs: Any ) -> None: ... namespace azure.servicebus.aio.management - class azure.servicebus.aio.management.ServiceBusAdministrationClient: implements AsyncContextManager + class azure.servicebus.aio.management.ServiceBusAdministrationClient: implements AsyncContextManager def __init__( - self, - fully_qualified_namespace: str, - credential: AsyncTokenCredential, - *, - api_version: Union[str, ApiVersion] = DEFAULT_VERSION, + self, + fully_qualified_namespace: str, + credential: AsyncTokenCredential, + *, + api_version: Union[str, ApiVersion] = DEFAULT_VERSION, **kwargs: Any ) -> None: ... @classmethod def from_connection_string( - cls, - conn_str: str, - *, - api_version: Union[str, ApiVersion] = DEFAULT_VERSION, + cls, + conn_str: str, + *, + api_version: Union[str, ApiVersion] = DEFAULT_VERSION, **kwargs: Any ) -> ServiceBusAdministrationClient: ... async def close(self) -> None: ... async def create_queue( - self, - queue_name: str, - *, - authorization_rules: Optional[List[AuthorizationRule]] = ..., - auto_delete_on_idle: Optional[Union[timedelta, str]] = ..., - dead_lettering_on_message_expiration: Optional[bool] = ..., - default_message_time_to_live: Optional[Union[timedelta, str]] = ..., - duplicate_detection_history_time_window: Optional[Union[timedelta, str]] = ..., - enable_batched_operations: Optional[bool] = ..., - enable_express: Optional[bool] = ..., - enable_partitioning: Optional[bool] = ..., - forward_dead_lettered_messages_to: Optional[str] = ..., - forward_to: Optional[str] = ..., - lock_duration: Optional[Union[timedelta, str]] = ..., - max_delivery_count: Optional[int] = ..., - max_message_size_in_kilobytes: Optional[int] = ..., - max_size_in_megabytes: Optional[int] = ..., - requires_duplicate_detection: Optional[bool] = ..., - requires_session: Optional[bool] = ..., - user_metadata: Optional[str] = ..., + self, + queue_name: str, + *, + authorization_rules: Optional[List[AuthorizationRule]] = ..., + auto_delete_on_idle: Optional[Union[timedelta, str]] = ..., + dead_lettering_on_message_expiration: Optional[bool] = ..., + default_message_time_to_live: Optional[Union[timedelta, str]] = ..., + duplicate_detection_history_time_window: Optional[Union[timedelta, str]] = ..., + enable_batched_operations: Optional[bool] = ..., + enable_express: Optional[bool] = ..., + enable_partitioning: Optional[bool] = ..., + forward_dead_lettered_messages_to: Optional[str] = ..., + forward_to: Optional[str] = ..., + lock_duration: Optional[Union[timedelta, str]] = ..., + max_delivery_count: Optional[int] = ..., + max_message_size_in_kilobytes: Optional[int] = ..., + max_size_in_megabytes: Optional[int] = ..., + requires_duplicate_detection: Optional[bool] = ..., + requires_session: Optional[bool] = ..., + user_metadata: Optional[str] = ..., **kwargs: Any ) -> QueueProperties: ... async def create_rule( - self, - topic_name: str, - subscription_name: str, - rule_name: str, - *, - action: Optional[SqlRuleAction] = ..., - filter: Union[CorrelationRuleFilter, SqlRuleFilter] = TrueRuleFilter(), + self, + topic_name: str, + subscription_name: str, + rule_name: str, + *, + action: Optional[SqlRuleAction] = ..., + filter: Union[CorrelationRuleFilter, SqlRuleFilter] = TrueRuleFilter(), **kwargs: Any ) -> RuleProperties: ... async def create_subscription( - self, - topic_name: str, - subscription_name: str, - *, - auto_delete_on_idle: Optional[Union[timedelta, str]] = ..., - dead_lettering_on_filter_evaluation_exceptions: Optional[bool] = ..., - dead_lettering_on_message_expiration: Optional[bool] = ..., - default_message_time_to_live: Optional[Union[timedelta, str]] = ..., - enable_batched_operations: Optional[bool] = ..., - forward_dead_lettered_messages_to: Optional[str] = ..., - forward_to: Optional[str] = ..., - lock_duration: Optional[Union[timedelta, str]] = ..., - max_delivery_count: Optional[int] = ..., - requires_session: Optional[bool] = ..., - user_metadata: Optional[str] = ..., + self, + topic_name: str, + subscription_name: str, + *, + auto_delete_on_idle: Optional[Union[timedelta, str]] = ..., + dead_lettering_on_filter_evaluation_exceptions: Optional[bool] = ..., + dead_lettering_on_message_expiration: Optional[bool] = ..., + default_message_time_to_live: Optional[Union[timedelta, str]] = ..., + enable_batched_operations: Optional[bool] = ..., + forward_dead_lettered_messages_to: Optional[str] = ..., + forward_to: Optional[str] = ..., + lock_duration: Optional[Union[timedelta, str]] = ..., + max_delivery_count: Optional[int] = ..., + requires_session: Optional[bool] = ..., + user_metadata: Optional[str] = ..., **kwargs: Any ) -> SubscriptionProperties: ... async def create_topic( - self, - topic_name: str, - *, - authorization_rules: Optional[List[AuthorizationRule]] = ..., - auto_delete_on_idle: Optional[Union[timedelta, str]] = ..., - default_message_time_to_live: Optional[Union[timedelta, str]] = ..., - duplicate_detection_history_time_window: Optional[Union[timedelta, str]] = ..., - enable_batched_operations: Optional[bool] = ..., - enable_express: Optional[bool] = ..., - enable_partitioning: Optional[bool] = ..., - filtering_messages_before_publishing: Optional[bool] = ..., - max_message_size_in_kilobytes: Optional[int] = ..., - max_size_in_megabytes: Optional[int] = ..., - requires_duplicate_detection: Optional[bool] = ..., - size_in_bytes: Optional[int] = ..., - support_ordering: Optional[bool] = ..., - user_metadata: Optional[str] = ..., + self, + topic_name: str, + *, + authorization_rules: Optional[List[AuthorizationRule]] = ..., + auto_delete_on_idle: Optional[Union[timedelta, str]] = ..., + default_message_time_to_live: Optional[Union[timedelta, str]] = ..., + duplicate_detection_history_time_window: Optional[Union[timedelta, str]] = ..., + enable_batched_operations: Optional[bool] = ..., + enable_express: Optional[bool] = ..., + enable_partitioning: Optional[bool] = ..., + filtering_messages_before_publishing: Optional[bool] = ..., + max_message_size_in_kilobytes: Optional[int] = ..., + max_size_in_megabytes: Optional[int] = ..., + requires_duplicate_detection: Optional[bool] = ..., + size_in_bytes: Optional[int] = ..., + support_ordering: Optional[bool] = ..., + user_metadata: Optional[str] = ..., **kwargs: Any ) -> TopicProperties: ... async def delete_queue( - self, - queue_name: str, + self, + queue_name: str, **kwargs: Any ) -> None: ... async def delete_rule( - self, - topic_name: str, - subscription_name: str, - rule_name: str, + self, + topic_name: str, + subscription_name: str, + rule_name: str, **kwargs: Any ) -> None: ... async def delete_subscription( - self, - topic_name: str, - subscription_name: str, + self, + topic_name: str, + subscription_name: str, **kwargs: Any ) -> None: ... async def delete_topic( - self, - topic_name: str, + self, + topic_name: str, **kwargs: Any ) -> None: ... async def get_namespace_properties(self, **kwargs: Any) -> NamespaceProperties: ... async def get_queue( - self, - queue_name: str, + self, + queue_name: str, **kwargs: Any ) -> QueueProperties: ... async def get_queue_runtime_properties( - self, - queue_name: str, + self, + queue_name: str, **kwargs: Any ) -> QueueRuntimeProperties: ... async def get_rule( - self, - topic_name: str, - subscription_name: str, - rule_name: str, + self, + topic_name: str, + subscription_name: str, + rule_name: str, **kwargs: Any ) -> RuleProperties: ... async def get_subscription( - self, - topic_name: str, - subscription_name: str, + self, + topic_name: str, + subscription_name: str, **kwargs: Any ) -> SubscriptionProperties: ... async def get_subscription_runtime_properties( - self, - topic_name: str, - subscription_name: str, + self, + topic_name: str, + subscription_name: str, **kwargs: Any ) -> SubscriptionRuntimeProperties: ... async def get_topic( - self, - topic_name: str, + self, + topic_name: str, **kwargs: Any ) -> TopicProperties: ... async def get_topic_runtime_properties( - self, - topic_name: str, + self, + topic_name: str, **kwargs: Any ) -> TopicRuntimeProperties: ... @@ -972,21 +1018,21 @@ namespace azure.servicebus.aio.management def list_queues_runtime_properties(self, **kwargs: Any) -> AsyncItemPaged[QueueRuntimeProperties]: ... def list_rules( - self, - topic_name: str, - subscription_name: str, + self, + topic_name: str, + subscription_name: str, **kwargs: Any ) -> AsyncItemPaged[RuleProperties]: ... def list_subscriptions( - self, - topic_name: str, + self, + topic_name: str, **kwargs: Any ) -> AsyncItemPaged[SubscriptionProperties]: ... def list_subscriptions_runtime_properties( - self, - topic_name: str, + self, + topic_name: str, **kwargs: Any ) -> AsyncItemPaged[SubscriptionRuntimeProperties]: ... @@ -995,29 +1041,29 @@ namespace azure.servicebus.aio.management def list_topics_runtime_properties(self, **kwargs: Any) -> AsyncItemPaged[TopicRuntimeProperties]: ... async def update_queue( - self, - queue: Union[QueueProperties, Mapping[str, Any]], + self, + queue: Union[QueueProperties, Mapping[str, Any]], **kwargs: Any ) -> None: ... async def update_rule( - self, - topic_name: str, - subscription_name: str, - rule: Union[RuleProperties, Mapping[str, Any]], + self, + topic_name: str, + subscription_name: str, + rule: Union[RuleProperties, Mapping[str, Any]], **kwargs: Any ) -> None: ... async def update_subscription( - self, - topic_name: str, - subscription: Union[SubscriptionProperties, Mapping[str, Any]], + self, + topic_name: str, + subscription: Union[SubscriptionProperties, Mapping[str, Any]], **kwargs: Any ) -> None: ... async def update_topic( - self, - topic: Union[TopicProperties, Mapping[str, Any]], + self, + topic: Union[TopicProperties, Mapping[str, Any]], **kwargs: Any ) -> None: ... @@ -1035,17 +1081,17 @@ namespace azure.servicebus.amqp property properties: Optional[AmqpMessageProperties] def __init__( - self, - *, - annotations: Optional[Dict[str, Any]] = ..., - application_properties: Optional[Dict[str, Any]] = ..., - data_body: Union[str, bytes, list[str, bytes]] = ..., - delivery_annotations: Optional[Dict[str, Any]] = ..., - footer: Optional[Dict[str, Any]] = ..., - header: Optional[Union[AmqpMessageHeader, Mapping[str, Any]]] = ..., - properties: Optional[Union[AmqpMessageProperties, Mapping[str, Any]]] = ..., - sequence_body: list[any] = ..., - value_body: any = ..., + self, + *, + annotations: Optional[Dict[str, Any]] = ..., + application_properties: Optional[Dict[str, Any]] = ..., + data_body: Union[str, bytes, list[str, bytes]] = ..., + delivery_annotations: Optional[Dict[str, Any]] = ..., + footer: Optional[Dict[str, Any]] = ..., + header: Optional[Union[AmqpMessageHeader, Mapping[str, Any]]] = ..., + properties: Optional[Union[AmqpMessageProperties, Mapping[str, Any]]] = ..., + sequence_body: list[any] = ..., + value_body: any = ..., **kwargs: Any ) -> None: ... @@ -1076,13 +1122,13 @@ namespace azure.servicebus.amqp def __getitem__(self, key: str) -> Any: ... def __init__( - self, - *, - delivery_count: Optional[int] = ..., - durable: Optional[bool] = ..., - first_acquirer: Optional[bool] = ..., - priority: Optional[int] = ..., - time_to_live: Optional[int] = ..., + self, + *, + delivery_count: Optional[int] = ..., + durable: Optional[bool] = ..., + first_acquirer: Optional[bool] = ..., + priority: Optional[int] = ..., + time_to_live: Optional[int] = ..., **kwargs: Any ): ... @@ -1093,16 +1139,16 @@ namespace azure.servicebus.amqp def __repr__(self) -> str: ... def __setitem__( - self, - key: str, + self, + key: str, item: Any ) -> None: ... def __str__(self) -> str: ... def get( - self, - key: str, + self, + key: str, default: Optional[Any] = None ) -> Any: ... @@ -1113,8 +1159,8 @@ namespace azure.servicebus.amqp def keys(self) -> List[str]: ... def update( - self, - *args: Any, + self, + *args: Any, **kwargs: Any ) -> None: ... @@ -1145,21 +1191,21 @@ namespace azure.servicebus.amqp def __getitem__(self, key: str) -> Any: ... def __init__( - self, - *, - absolute_expiry_time: Optional[int] = ..., - content_encoding: Optional[Union[str, bytes]] = ..., - content_type: Optional[Union[str, bytes]] = ..., - correlation_id: Optional[Union[str, bytes]] = ..., - creation_time: Optional[int] = ..., - group_id: Optional[Union[str, bytes]] = ..., - group_sequence: Optional[int] = ..., - message_id: Optional[Union[str, bytes, uuid.UUID]] = ..., - reply_to: Optional[Union[str, bytes]] = ..., - reply_to_group_id: Optional[Union[str, bytes]] = ..., - subject: Optional[Union[str, bytes]] = ..., - to: Optional[Union[str, bytes]] = ..., - user_id: Optional[Union[str, bytes]] = ..., + self, + *, + absolute_expiry_time: Optional[int] = ..., + content_encoding: Optional[Union[str, bytes]] = ..., + content_type: Optional[Union[str, bytes]] = ..., + correlation_id: Optional[Union[str, bytes]] = ..., + creation_time: Optional[int] = ..., + group_id: Optional[Union[str, bytes]] = ..., + group_sequence: Optional[int] = ..., + message_id: Optional[Union[str, bytes, uuid.UUID]] = ..., + reply_to: Optional[Union[str, bytes]] = ..., + reply_to_group_id: Optional[Union[str, bytes]] = ..., + subject: Optional[Union[str, bytes]] = ..., + to: Optional[Union[str, bytes]] = ..., + user_id: Optional[Union[str, bytes]] = ..., **kwargs: Any ): ... @@ -1170,16 +1216,16 @@ namespace azure.servicebus.amqp def __repr__(self) -> str: ... def __setitem__( - self, - key: str, + self, + key: str, item: Any ) -> None: ... def __str__(self) -> str: ... def get( - self, - key: str, + self, + key: str, default: Optional[Any] = None ) -> Any: ... @@ -1190,8 +1236,8 @@ namespace azure.servicebus.amqp def keys(self) -> List[str]: ... def update( - self, - *args: Any, + self, + *args: Any, **kwargs: Any ) -> None: ... @@ -1203,9 +1249,9 @@ namespace azure.servicebus.exceptions class azure.servicebus.exceptions.AutoLockRenewFailed(ServiceBusError): def __init__( - self, - message: Optional[Union[str, bytes]], - *args: Any, + self, + message: Optional[Union[str, bytes]], + *args: Any, **kwargs: Any ) -> None: ... @@ -1213,9 +1259,9 @@ namespace azure.servicebus.exceptions class azure.servicebus.exceptions.AutoLockRenewTimeout(ServiceBusError): def __init__( - self, - message: Optional[Union[str, bytes]], - *args: Any, + self, + message: Optional[Union[str, bytes]], + *args: Any, **kwargs: Any ) -> None: ... @@ -1288,11 +1334,11 @@ namespace azure.servicebus.exceptions message: str def __init__( - self, - message: Optional[Union[str, bytes]], - *args: Any, - *, - error: Exception = ..., + self, + message: Optional[Union[str, bytes]], + *args: Any, + *, + error: Exception = ..., **kwargs: Any ) -> None: ... @@ -1334,16 +1380,16 @@ namespace azure.servicebus.management class azure.servicebus.management.AuthorizationRule: def __init__( - self, - *, - claim_type: Optional[str] = ..., - claim_value: Optional[str] = ..., - created_at_utc: Optional[datetime] = ..., - key_name: Optional[str] = ..., - modified_at_utc: Optional[datetime] = ..., - primary_key: Optional[str] = ..., - rights: Optional[List[Union[str, AccessRights]]] = ..., - secondary_key: Optional[str] = ..., + self, + *, + claim_type: Optional[str] = ..., + claim_value: Optional[str] = ..., + created_at_utc: Optional[datetime] = ..., + key_name: Optional[str] = ..., + modified_at_utc: Optional[datetime] = ..., + primary_key: Optional[str] = ..., + rights: Optional[List[Union[str, AccessRights]]] = ..., + secondary_key: Optional[str] = ..., type: Optional[str] = ... ) -> None: ... @@ -1351,16 +1397,16 @@ namespace azure.servicebus.management class azure.servicebus.management.CorrelationRuleFilter: def __init__( - self, - *, - content_type: Optional[str] = ..., - correlation_id: Optional[str] = ..., - label: Optional[str] = ..., - message_id: Optional[str] = ..., - properties: Optional[Dict[str, Union[str, int, float, bool, datetime, timedelta]]] = ..., - reply_to: Optional[str] = ..., - reply_to_session_id: Optional[str] = ..., - session_id: Optional[str] = ..., + self, + *, + content_type: Optional[str] = ..., + correlation_id: Optional[str] = ..., + label: Optional[str] = ..., + message_id: Optional[str] = ..., + properties: Optional[Dict[str, Union[str, int, float, bool, datetime, timedelta]]] = ..., + reply_to: Optional[str] = ..., + reply_to_session_id: Optional[str] = ..., + session_id: Optional[str] = ..., to: Optional[str] = ... ) -> None: ... @@ -1400,13 +1446,13 @@ namespace azure.servicebus.management def __eq__(self, other: Any) -> bool: ... def __init__( - self, - *, - active_message_count: Optional[int] = ..., - dead_letter_message_count: Optional[int] = ..., - scheduled_message_count: Optional[int] = ..., - transfer_dead_letter_message_count: Optional[int] = ..., - transfer_message_count: Optional[int] = ..., + self, + *, + active_message_count: Optional[int] = ..., + dead_letter_message_count: Optional[int] = ..., + scheduled_message_count: Optional[int] = ..., + transfer_dead_letter_message_count: Optional[int] = ..., + transfer_message_count: Optional[int] = ..., **kwargs: Any ) -> None: ... @@ -1416,8 +1462,8 @@ namespace azure.servicebus.management @classmethod def deserialize( - cls: Type[ModelType], - data: Any, + cls: Type[ModelType], + data: Any, content_type: Optional[str] = None ) -> ModelType: ... @@ -1426,9 +1472,9 @@ namespace azure.servicebus.management @classmethod def from_dict( - cls: Type[ModelType], - data: Any, - key_extractors: Optional[Callable[[str, Dict[str, Any], Any], Any]] = None, + cls: Type[ModelType], + data: Any, + key_extractors: Optional[Callable[[str, Dict[str, Any], Any], Any]] = None, content_type: Optional[str] = None ) -> ModelType: ... @@ -1436,15 +1482,15 @@ namespace azure.servicebus.management def is_xml_model(cls) -> bool: ... def as_dict( - self, - keep_readonly: bool = True, - key_transformer: Callable[[str, Dict[str, Any], Any], Any] = attribute_transformer, + self, + keep_readonly: bool = True, + key_transformer: Callable[[str, Dict[str, Any], Any], Any] = attribute_transformer, **kwargs: Any ) -> JSON: ... def serialize( - self, - keep_readonly: bool = False, + self, + keep_readonly: bool = False, **kwargs: Any ) -> JSON: ... @@ -1473,14 +1519,14 @@ namespace azure.servicebus.management def __getitem__(self, key: str) -> Any: ... def __init__( - self, - name: str, - *, - alias: Optional[str], - created_at_utc: Optional[datetime], - messaging_sku: Optional[Union[str, MessagingSku]], - messaging_units: Optional[int], - modified_at_utc: Optional[datetime], + self, + name: str, + *, + alias: Optional[str], + created_at_utc: Optional[datetime], + messaging_sku: Optional[Union[str, MessagingSku]], + messaging_units: Optional[int], + modified_at_utc: Optional[datetime], namespace_type: Optional[Union[str, NamespaceType]] ) -> None: ... @@ -1491,16 +1537,16 @@ namespace azure.servicebus.management def __repr__(self) -> str: ... def __setitem__( - self, - key: str, + self, + key: str, item: Any ) -> None: ... def __str__(self) -> str: ... def get( - self, - key: str, + self, + key: str, default: Optional[Any] = None ) -> Any: ... @@ -1511,8 +1557,8 @@ namespace azure.servicebus.management def keys(self) -> List[str]: ... def update( - self, - *args: Any, + self, + *args: Any, **kwargs: Any ) -> None: ... @@ -1558,27 +1604,27 @@ namespace azure.servicebus.management def __getitem__(self, key: str) -> Any: ... def __init__( - self, - name: str, - *, - authorization_rules: Optional[List[AuthorizationRule]], - auto_delete_on_idle: Optional[Union[timedelta, str]], - availability_status: Optional[Union[str, EntityAvailabilityStatus]], - dead_lettering_on_message_expiration: Optional[bool], - default_message_time_to_live: Optional[Union[timedelta, str]], - duplicate_detection_history_time_window: Optional[Union[timedelta, str]], - enable_batched_operations: Optional[bool], - enable_express: Optional[bool], - enable_partitioning: Optional[bool], - forward_dead_lettered_messages_to: Optional[str], - forward_to: Optional[str], - lock_duration: Optional[Union[timedelta, str]], - max_delivery_count: Optional[int], - max_message_size_in_kilobytes: Optional[int], - max_size_in_megabytes: Optional[int], - requires_duplicate_detection: Optional[bool], - requires_session: Optional[bool], - status: Optional[Union[str, EntityStatus]], + self, + name: str, + *, + authorization_rules: Optional[List[AuthorizationRule]], + auto_delete_on_idle: Optional[Union[timedelta, str]], + availability_status: Optional[Union[str, EntityAvailabilityStatus]], + dead_lettering_on_message_expiration: Optional[bool], + default_message_time_to_live: Optional[Union[timedelta, str]], + duplicate_detection_history_time_window: Optional[Union[timedelta, str]], + enable_batched_operations: Optional[bool], + enable_express: Optional[bool], + enable_partitioning: Optional[bool], + forward_dead_lettered_messages_to: Optional[str], + forward_to: Optional[str], + lock_duration: Optional[Union[timedelta, str]], + max_delivery_count: Optional[int], + max_message_size_in_kilobytes: Optional[int], + max_size_in_megabytes: Optional[int], + requires_duplicate_detection: Optional[bool], + requires_session: Optional[bool], + status: Optional[Union[str, EntityStatus]], user_metadata: Optional[str] ) -> None: ... @@ -1589,16 +1635,16 @@ namespace azure.servicebus.management def __repr__(self) -> str: ... def __setitem__( - self, - key: str, + self, + key: str, item: Any ) -> None: ... def __str__(self) -> str: ... def get( - self, - key: str, + self, + key: str, default: Optional[Any] = None ) -> Any: ... @@ -1609,8 +1655,8 @@ namespace azure.servicebus.management def keys(self) -> List[str]: ... def update( - self, - *args: Any, + self, + *args: Any, **kwargs: Any ) -> None: ... @@ -1648,11 +1694,11 @@ namespace azure.servicebus.management def __getitem__(self, key: str) -> Any: ... def __init__( - self, - name: str, - *, - action: Optional[SqlRuleAction], - created_at_utc: Optional[datetime], + self, + name: str, + *, + action: Optional[SqlRuleAction], + created_at_utc: Optional[datetime], filter: Optional[Union[CorrelationRuleFilter, SqlRuleFilter]] ) -> None: ... @@ -1663,16 +1709,16 @@ namespace azure.servicebus.management def __repr__(self) -> str: ... def __setitem__( - self, - key: str, + self, + key: str, item: Any ) -> None: ... def __str__(self) -> str: ... def get( - self, - key: str, + self, + key: str, default: Optional[Any] = None ) -> Any: ... @@ -1683,183 +1729,183 @@ namespace azure.servicebus.management def keys(self) -> List[str]: ... def update( - self, - *args: Any, + self, + *args: Any, **kwargs: Any ) -> None: ... def values(self) -> List[Any]: ... - class azure.servicebus.management.ServiceBusAdministrationClient: implements ContextManager + class azure.servicebus.management.ServiceBusAdministrationClient: implements ContextManager def __init__( - self, - fully_qualified_namespace: str, - credential: TokenCredential, - *, - api_version: Union[str, ApiVersion] = DEFAULT_VERSION, + self, + fully_qualified_namespace: str, + credential: TokenCredential, + *, + api_version: Union[str, ApiVersion] = DEFAULT_VERSION, **kwargs: Any ) -> None: ... @classmethod def from_connection_string( - cls, - conn_str: str, - *, - api_version: Union[str, ApiVersion] = DEFAULT_VERSION, + cls, + conn_str: str, + *, + api_version: Union[str, ApiVersion] = DEFAULT_VERSION, **kwargs: Any ) -> ServiceBusAdministrationClient: ... def close(self) -> None: ... def create_queue( - self, - queue_name: str, - *, - authorization_rules: Optional[List[AuthorizationRule]] = ..., - auto_delete_on_idle: Optional[Union[timedelta, str]] = ..., - dead_lettering_on_message_expiration: Optional[bool] = ..., - default_message_time_to_live: Optional[Union[timedelta, str]] = ..., - duplicate_detection_history_time_window: Optional[Union[timedelta, str]] = ..., - enable_batched_operations: Optional[bool] = ..., - enable_express: Optional[bool] = ..., - enable_partitioning: Optional[bool] = ..., - forward_dead_lettered_messages_to: Optional[str] = ..., - forward_to: Optional[str] = ..., - lock_duration: Optional[Union[timedelta, str]] = ..., - max_delivery_count: Optional[int] = ..., - max_message_size_in_kilobytes: Optional[int] = ..., - max_size_in_megabytes: Optional[int] = ..., - requires_duplicate_detection: Optional[bool] = ..., - requires_session: Optional[bool] = ..., - user_metadata: Optional[str] = ..., + self, + queue_name: str, + *, + authorization_rules: Optional[List[AuthorizationRule]] = ..., + auto_delete_on_idle: Optional[Union[timedelta, str]] = ..., + dead_lettering_on_message_expiration: Optional[bool] = ..., + default_message_time_to_live: Optional[Union[timedelta, str]] = ..., + duplicate_detection_history_time_window: Optional[Union[timedelta, str]] = ..., + enable_batched_operations: Optional[bool] = ..., + enable_express: Optional[bool] = ..., + enable_partitioning: Optional[bool] = ..., + forward_dead_lettered_messages_to: Optional[str] = ..., + forward_to: Optional[str] = ..., + lock_duration: Optional[Union[timedelta, str]] = ..., + max_delivery_count: Optional[int] = ..., + max_message_size_in_kilobytes: Optional[int] = ..., + max_size_in_megabytes: Optional[int] = ..., + requires_duplicate_detection: Optional[bool] = ..., + requires_session: Optional[bool] = ..., + user_metadata: Optional[str] = ..., **kwargs: Any ) -> QueueProperties: ... def create_rule( - self, - topic_name: str, - subscription_name: str, - rule_name: str, - *, - action: Optional[SqlRuleAction] = ..., - filter: Union[CorrelationRuleFilter, SqlRuleFilter] = TrueRuleFilter(), + self, + topic_name: str, + subscription_name: str, + rule_name: str, + *, + action: Optional[SqlRuleAction] = ..., + filter: Union[CorrelationRuleFilter, SqlRuleFilter] = TrueRuleFilter(), **kwargs: Any ) -> RuleProperties: ... def create_subscription( - self, - topic_name: str, - subscription_name: str, - *, - auto_delete_on_idle: Optional[Union[timedelta, str]] = ..., - dead_lettering_on_filter_evaluation_exceptions: Optional[bool] = ..., - dead_lettering_on_message_expiration: Optional[bool] = ..., - default_message_time_to_live: Optional[Union[timedelta, str]] = ..., - enable_batched_operations: Optional[bool] = ..., - forward_dead_lettered_messages_to: Optional[str] = ..., - forward_to: Optional[str] = ..., - lock_duration: Optional[Union[timedelta, str]] = ..., - max_delivery_count: Optional[int] = ..., - requires_session: Optional[bool] = ..., - user_metadata: Optional[str] = ..., + self, + topic_name: str, + subscription_name: str, + *, + auto_delete_on_idle: Optional[Union[timedelta, str]] = ..., + dead_lettering_on_filter_evaluation_exceptions: Optional[bool] = ..., + dead_lettering_on_message_expiration: Optional[bool] = ..., + default_message_time_to_live: Optional[Union[timedelta, str]] = ..., + enable_batched_operations: Optional[bool] = ..., + forward_dead_lettered_messages_to: Optional[str] = ..., + forward_to: Optional[str] = ..., + lock_duration: Optional[Union[timedelta, str]] = ..., + max_delivery_count: Optional[int] = ..., + requires_session: Optional[bool] = ..., + user_metadata: Optional[str] = ..., **kwargs: Any ) -> SubscriptionProperties: ... def create_topic( - self, - topic_name: str, - *, - authorization_rules: Optional[List[AuthorizationRule]] = ..., - auto_delete_on_idle: Optional[Union[timedelta, str]] = ..., - default_message_time_to_live: Optional[Union[timedelta, str]] = ..., - duplicate_detection_history_time_window: Optional[Union[timedelta, str]] = ..., - enable_batched_operations: Optional[bool] = ..., - enable_express: Optional[bool] = ..., - enable_partitioning: Optional[bool] = ..., - filtering_messages_before_publishing: Optional[bool] = ..., - max_message_size_in_kilobytes: Optional[int] = ..., - max_size_in_megabytes: Optional[int] = ..., - requires_duplicate_detection: Optional[bool] = ..., - size_in_bytes: Optional[int] = ..., - support_ordering: Optional[bool] = ..., - user_metadata: Optional[str] = ..., + self, + topic_name: str, + *, + authorization_rules: Optional[List[AuthorizationRule]] = ..., + auto_delete_on_idle: Optional[Union[timedelta, str]] = ..., + default_message_time_to_live: Optional[Union[timedelta, str]] = ..., + duplicate_detection_history_time_window: Optional[Union[timedelta, str]] = ..., + enable_batched_operations: Optional[bool] = ..., + enable_express: Optional[bool] = ..., + enable_partitioning: Optional[bool] = ..., + filtering_messages_before_publishing: Optional[bool] = ..., + max_message_size_in_kilobytes: Optional[int] = ..., + max_size_in_megabytes: Optional[int] = ..., + requires_duplicate_detection: Optional[bool] = ..., + size_in_bytes: Optional[int] = ..., + support_ordering: Optional[bool] = ..., + user_metadata: Optional[str] = ..., **kwargs: Any ) -> TopicProperties: ... def delete_queue( - self, - queue_name: str, + self, + queue_name: str, **kwargs: Any ) -> None: ... def delete_rule( - self, - topic_name: str, - subscription_name: str, - rule_name: str, + self, + topic_name: str, + subscription_name: str, + rule_name: str, **kwargs: Any ) -> None: ... def delete_subscription( - self, - topic_name: str, - subscription_name: str, + self, + topic_name: str, + subscription_name: str, **kwargs: Any ) -> None: ... def delete_topic( - self, - topic_name: str, + self, + topic_name: str, **kwargs: Any ) -> None: ... def get_namespace_properties(self, **kwargs: Any) -> NamespaceProperties: ... def get_queue( - self, - queue_name: str, + self, + queue_name: str, **kwargs: Any ) -> QueueProperties: ... def get_queue_runtime_properties( - self, - queue_name: str, + self, + queue_name: str, **kwargs: Any ) -> QueueRuntimeProperties: ... def get_rule( - self, - topic_name: str, - subscription_name: str, - rule_name: str, + self, + topic_name: str, + subscription_name: str, + rule_name: str, **kwargs: Any ) -> RuleProperties: ... def get_subscription( - self, - topic_name: str, - subscription_name: str, + self, + topic_name: str, + subscription_name: str, **kwargs: Any ) -> SubscriptionProperties: ... def get_subscription_runtime_properties( - self, - topic_name: str, - subscription_name: str, + self, + topic_name: str, + subscription_name: str, **kwargs: Any ) -> SubscriptionRuntimeProperties: ... def get_topic( - self, - topic_name: str, + self, + topic_name: str, **kwargs: Any ) -> TopicProperties: ... def get_topic_runtime_properties( - self, - topic_name: str, + self, + topic_name: str, **kwargs: Any ) -> TopicRuntimeProperties: ... @@ -1868,21 +1914,21 @@ namespace azure.servicebus.management def list_queues_runtime_properties(self, **kwargs: Any) -> ItemPaged[QueueRuntimeProperties]: ... def list_rules( - self, - topic_name: str, - subscription_name: str, + self, + topic_name: str, + subscription_name: str, **kwargs: Any ) -> ItemPaged[RuleProperties]: ... def list_subscriptions( - self, - topic_name: str, + self, + topic_name: str, **kwargs: Any ) -> ItemPaged[SubscriptionProperties]: ... def list_subscriptions_runtime_properties( - self, - topic_name: str, + self, + topic_name: str, **kwargs: Any ) -> ItemPaged[SubscriptionRuntimeProperties]: ... @@ -1891,29 +1937,29 @@ namespace azure.servicebus.management def list_topics_runtime_properties(self, **kwargs: Any) -> ItemPaged[TopicRuntimeProperties]: ... def update_queue( - self, - queue: Union[QueueProperties, Mapping[str, Any]], + self, + queue: Union[QueueProperties, Mapping[str, Any]], **kwargs: Any ) -> None: ... def update_rule( - self, - topic_name: str, - subscription_name: str, - rule: Union[RuleProperties, Mapping[str, Any]], + self, + topic_name: str, + subscription_name: str, + rule: Union[RuleProperties, Mapping[str, Any]], **kwargs: Any ) -> None: ... def update_subscription( - self, - topic_name: str, - subscription: Union[SubscriptionProperties, Mapping[str, Any]], + self, + topic_name: str, + subscription: Union[SubscriptionProperties, Mapping[str, Any]], **kwargs: Any ) -> None: ... def update_topic( - self, - topic: Union[TopicProperties, Mapping[str, Any]], + self, + topic: Union[TopicProperties, Mapping[str, Any]], **kwargs: Any ) -> None: ... @@ -1921,8 +1967,8 @@ namespace azure.servicebus.management class azure.servicebus.management.SqlRuleAction: def __init__( - self, - sql_expression: Optional[str] = None, + self, + sql_expression: Optional[str] = None, parameters: Optional[Dict[str, Union[str, int, float, bool, datetime, timedelta]]] = None ) -> None: ... @@ -1930,8 +1976,8 @@ namespace azure.servicebus.management class azure.servicebus.management.SqlRuleFilter: def __init__( - self, - sql_expression: Optional[str] = None, + self, + sql_expression: Optional[str] = None, parameters: Optional[Dict[str, Union[str, int, float, bool, datetime, timedelta]]] = None ) -> None: ... @@ -1961,21 +2007,21 @@ namespace azure.servicebus.management def __getitem__(self, key: str) -> Any: ... def __init__( - self, - name: str, - *, - auto_delete_on_idle: Optional[Union[timedelta, str]], - availability_status: Optional[Union[str, EntityAvailabilityStatus]], - dead_lettering_on_filter_evaluation_exceptions: Optional[bool], - dead_lettering_on_message_expiration: Optional[bool], - default_message_time_to_live: Optional[Union[timedelta, str]], - enable_batched_operations: Optional[bool], - forward_dead_lettered_messages_to: Optional[str], - forward_to: Optional[str], - lock_duration: Optional[Union[timedelta, str]], - max_delivery_count: Optional[int], - requires_session: Optional[bool], - status: Optional[Union[str, EntityStatus]], + self, + name: str, + *, + auto_delete_on_idle: Optional[Union[timedelta, str]], + availability_status: Optional[Union[str, EntityAvailabilityStatus]], + dead_lettering_on_filter_evaluation_exceptions: Optional[bool], + dead_lettering_on_message_expiration: Optional[bool], + default_message_time_to_live: Optional[Union[timedelta, str]], + enable_batched_operations: Optional[bool], + forward_dead_lettered_messages_to: Optional[str], + forward_to: Optional[str], + lock_duration: Optional[Union[timedelta, str]], + max_delivery_count: Optional[int], + requires_session: Optional[bool], + status: Optional[Union[str, EntityStatus]], user_metadata: Optional[str] ) -> None: ... @@ -1986,16 +2032,16 @@ namespace azure.servicebus.management def __repr__(self) -> str: ... def __setitem__( - self, - key: str, + self, + key: str, item: Any ) -> None: ... def __str__(self) -> str: ... def get( - self, - key: str, + self, + key: str, default: Optional[Any] = None ) -> Any: ... @@ -2006,8 +2052,8 @@ namespace azure.servicebus.management def keys(self) -> List[str]: ... def update( - self, - *args: Any, + self, + *args: Any, **kwargs: Any ) -> None: ... @@ -2056,24 +2102,24 @@ namespace azure.servicebus.management def __getitem__(self, key: str) -> Any: ... def __init__( - self, - name: str, - *, - authorization_rules: Optional[List[AuthorizationRule]], - auto_delete_on_idle: Optional[Union[timedelta, str]], - availability_status: Optional[Union[str, EntityAvailabilityStatus]], - default_message_time_to_live: Optional[Union[timedelta, str]], - duplicate_detection_history_time_window: Optional[Union[timedelta, str]], - enable_batched_operations: Optional[bool], - enable_express: Optional[bool], - enable_partitioning: Optional[bool], - filtering_messages_before_publishing: Optional[bool], - max_message_size_in_kilobytes: Optional[int], - max_size_in_megabytes: Optional[int], - requires_duplicate_detection: Optional[bool], - size_in_bytes: Optional[int], - status: Optional[Union[str, EntityStatus]], - support_ordering: Optional[bool], + self, + name: str, + *, + authorization_rules: Optional[List[AuthorizationRule]], + auto_delete_on_idle: Optional[Union[timedelta, str]], + availability_status: Optional[Union[str, EntityAvailabilityStatus]], + default_message_time_to_live: Optional[Union[timedelta, str]], + duplicate_detection_history_time_window: Optional[Union[timedelta, str]], + enable_batched_operations: Optional[bool], + enable_express: Optional[bool], + enable_partitioning: Optional[bool], + filtering_messages_before_publishing: Optional[bool], + max_message_size_in_kilobytes: Optional[int], + max_size_in_megabytes: Optional[int], + requires_duplicate_detection: Optional[bool], + size_in_bytes: Optional[int], + status: Optional[Union[str, EntityStatus]], + support_ordering: Optional[bool], user_metadata: Optional[str] ) -> None: ... @@ -2084,16 +2130,16 @@ namespace azure.servicebus.management def __repr__(self) -> str: ... def __setitem__( - self, - key: str, + self, + key: str, item: Any ) -> None: ... def __str__(self) -> str: ... def get( - self, - key: str, + self, + key: str, default: Optional[Any] = None ) -> Any: ... @@ -2104,8 +2150,8 @@ namespace azure.servicebus.management def keys(self) -> List[str]: ... def update( - self, - *args: Any, + self, + *args: Any, **kwargs: Any ) -> None: ... diff --git a/sdk/servicebus/azure-servicebus/api.metadata.yml b/sdk/servicebus/azure-servicebus/api.metadata.yml index d4f1eaa42c46..eb64a2e611dc 100644 --- a/sdk/servicebus/azure-servicebus/api.metadata.yml +++ b/sdk/servicebus/azure-servicebus/api.metadata.yml @@ -1,3 +1,3 @@ -apiMdSha256: 70b3fbd3239df511d17fde454a717f9ef0a11f125b096da26d9c815ab0120a17 +apiMdSha256: 87c7a74eca4456c54fc09ea826778182be53acc571095c11a8ebe7b12a981dd0 parserVersion: 0.3.31 pythonVersion: 3.14.0 diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/__init__.py b/sdk/servicebus/azure-servicebus/azure/servicebus/__init__.py index 2ab2fa09dfd4..c3939c772350 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/__init__.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/__init__.py @@ -11,6 +11,7 @@ from ._servicebus_sender import ServiceBusSender from ._servicebus_receiver import ServiceBusReceiver from ._servicebus_session import ServiceBusSession +from ._models import DeleteMessagesResult, PurgeMessagesResult from ._common.message import ( ServiceBusMessage, ServiceBusMessageBatch, @@ -43,6 +44,8 @@ "ServiceBusReceiver", "ServiceBusSession", "ServiceBusSender", + "DeleteMessagesResult", + "PurgeMessagesResult", "TransportType", "AutoLockRenewer", "parse_connection_string", diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py index fa3baacfb287..cbefeb1c7ac9 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py @@ -553,8 +553,23 @@ def _mgmt_request_response_with_retry( def _open(self): raise ValueError("Subclass should override the method.") - def _open_with_retry(self): - return self._do_retryable_operation(self._open) + def _open_with_timeout(self, timeout: float): + del timeout + return self._open() + + def _open_with_retry(self, timeout: Optional[float] = None): + def open_with_timeout(timeout: Optional[float] = None): + if timeout is not None and timeout <= 0: + raise OperationTimeoutError() + if timeout is None: + return self._open() + return self._open_with_timeout(timeout) + + return self._do_retryable_operation( + open_with_timeout, + timeout=timeout, + operation_requires_timeout=timeout is not None, + ) def _close_handler(self): if self._handler: diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/constants.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/constants.py index 5f7c67d0e916..0ad3a00c3dad 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/constants.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/constants.py @@ -52,6 +52,7 @@ REQUEST_RESPONSE_ADD_RULE_OPERATION = VENDOR + b":add-rule" REQUEST_RESPONSE_REMOVE_RULE_OPERATION = VENDOR + b":remove-rule" REQUEST_RESPONSE_GET_RULES_OPERATION = VENDOR + b":enumerate-rules" +REQUEST_RESPONSE_BATCH_DELETE_MESSAGES_OPERATION = VENDOR + b":batch-delete-messages" CONTAINER_PREFIX = "servicebus.pysdk-" JWT_TOKEN_SCOPE = "https://servicebus.azure.net//.default" @@ -82,6 +83,8 @@ MGMT_REQUEST_RECEIVER_SETTLE_MODE = "receiver-settle-mode" MGMT_REQUEST_FROM_SEQUENCE_NUMBER = "from-sequence-number" MGMT_REQUEST_MAX_MESSAGE_COUNT = "message-count" +MGMT_REQUEST_MESSAGE_COUNT = "message-count" +MGMT_REQUEST_ENQUEUED_TIME_UTC = "enqueued-time-utc" MGMT_REQUEST_MESSAGE = "message" MGMT_REQUEST_MESSAGES = "messages" MGMT_REQUEST_MESSAGE_ID = "message-id" diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/mgmt_handlers.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/mgmt_handlers.py index 2dc9e87f38f5..4291fdbd408e 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/mgmt_handlers.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/mgmt_handlers.py @@ -69,6 +69,28 @@ def peek_op( # pylint: disable=inconsistent-return-statements ) +def batch_delete_op( # pylint: disable=inconsistent-return-statements + status_code, message, description, amqp_transport, max_message_count +): + condition = message.application_properties.get(MGMT_RESPONSE_MESSAGE_ERROR_CONDITION) + if status_code == 200 or (status_code == 404 and condition == ERROR_CODE_MESSAGE_NOT_FOUND): + deleted_count = message.value.get(b"message-count") if isinstance(message.value, dict) else None + if ( + isinstance(deleted_count, bool) + or not isinstance(deleted_count, int) + or deleted_count < 0 + or deleted_count > max_message_count + ): + raise ValueError("Batch delete response did not contain a valid message-count.") + return deleted_count + if status_code == 204: + return 0 + + amqp_transport.handle_amqp_mgmt_error( + _LOGGER, "Batch delete messages failed.", condition, description, status_code + ) + + def list_sessions_op( # pylint: disable=inconsistent-return-statements status_code, message, description, amqp_transport ): diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py index 15ec4c26ffd3..f69e2f0b3db0 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py @@ -72,6 +72,15 @@ # Number of seconds between the Unix epoch (1/1/1970) and year 1 CE. # This is the lowest value that can be represented by an AMQP timestamp. CE_ZERO_SECONDS: int = -62_135_596_800 +EPOCH_UTC: datetime.datetime = datetime.datetime(1970, 1, 1, tzinfo=TZ_UTC) + + +def datetime_to_timestamp_ms(value: datetime.datetime) -> int: + if value.tzinfo is None: + normalized = value.replace(tzinfo=TZ_UTC) + else: + normalized = value.astimezone(TZ_UTC) + return (normalized - EPOCH_UTC) // datetime.timedelta(milliseconds=1) def utc_from_timestamp(timestamp: float) -> datetime.datetime: """ diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_models.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_models.py new file mode 100644 index 000000000000..791a6d92c751 --- /dev/null +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_models.py @@ -0,0 +1,30 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- + + +class DeleteMessagesResult: + """The result of a batch delete operation. + + :ivar int deleted_message_count: The number of messages deleted by the service. + """ + + def __init__(self, deleted_message_count: int) -> None: + self._deleted_message_count = deleted_message_count + + @property + def deleted_message_count(self) -> int: + """The number of messages deleted by the service. + + :rtype: int + """ + return self._deleted_message_count + + +class PurgeMessagesResult(DeleteMessagesResult): + """The result of a purge operation. + + :ivar int deleted_message_count: The total number of messages deleted by the service. + """ diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py index 0bfb508db77f..a7b5422e9ef6 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py @@ -12,10 +12,11 @@ from enum import Enum from typing import Any, List, Optional, Dict, Iterator, Union, TYPE_CHECKING, cast -from .exceptions import MessageLockLostError +from .exceptions import MessageLockLostError, OperationTimeoutError from ._base_handler import BaseHandler from ._common.message import ServiceBusReceivedMessage -from ._common.utils import create_authentication +from ._models import DeleteMessagesResult, PurgeMessagesResult +from ._common.utils import create_authentication, datetime_to_timestamp_ms from ._common.tracing import ( get_receive_links, receive_trace_context_manager, @@ -30,6 +31,7 @@ REQUEST_RESPONSE_UPDATE_DISPOSTION_OPERATION, REQUEST_RESPONSE_RENEWLOCK_OPERATION, REQUEST_RESPONSE_PEEK_OPERATION, + REQUEST_RESPONSE_BATCH_DELETE_MESSAGES_OPERATION, ServiceBusReceiveMode, MGMT_REQUEST_DISPOSITION_STATUS, MGMT_REQUEST_LOCK_TOKENS, @@ -37,6 +39,8 @@ MGMT_REQUEST_RECEIVER_SETTLE_MODE, MGMT_REQUEST_FROM_SEQUENCE_NUMBER, MGMT_REQUEST_MAX_MESSAGE_COUNT, + MGMT_REQUEST_MESSAGE_COUNT, + MGMT_REQUEST_ENQUEUED_TIME_UTC, MESSAGE_COMPLETE, MESSAGE_ABANDON, MESSAGE_DEFER, @@ -54,7 +58,10 @@ if TYPE_CHECKING: try: - from uamqp import ReceiveClient as uamqp_ReceiveClientSync, Message as uamqp_Message + from uamqp import ( + ReceiveClient as uamqp_ReceiveClientSync, + Message as uamqp_Message, + ) from uamqp.authentication import JWTTokenAuth as uamqp_JWTTokenAuth except ImportError: pass @@ -72,7 +79,9 @@ _LOGGER = logging.getLogger(__name__) -class ServiceBusReceiver(BaseHandler, ReceiverMixin): # pylint: disable=too-many-instance-attributes +class ServiceBusReceiver( + BaseHandler, ReceiverMixin +): # pylint: disable=too-many-instance-attributes """The ServiceBusReceiver class defines a high level interface for receiving messages from the Azure Service Bus Queue or Topic Subscription. @@ -149,12 +158,16 @@ class ServiceBusReceiver(BaseHandler, ReceiverMixin): # pylint: disable=too-many def __init__( self, fully_qualified_namespace: str, - credential: Union["TokenCredential", "AzureSasCredential", "AzureNamedKeyCredential"], + credential: Union[ + "TokenCredential", "AzureSasCredential", "AzureNamedKeyCredential" + ], *, queue_name: Optional[str] = None, topic_name: Optional[str] = None, subscription_name: Optional[str] = None, - receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, + receive_mode: Union[ + ServiceBusReceiveMode, str + ] = ServiceBusReceiveMode.PEEK_LOCK, max_wait_time: Optional[float] = None, auto_lock_renewer: Optional["AutoLockRenewer"] = None, prefetch_count: int = 0, @@ -178,12 +191,18 @@ def __init__( ) else: if queue_name and topic_name: - raise ValueError("Queue/Topic name can not be specified simultaneously.") + raise ValueError( + "Queue/Topic name can not be specified simultaneously." + ) if topic_name and not subscription_name: - raise ValueError("Subscription name is missing for the topic. Please specify subscription_name.") + raise ValueError( + "Subscription name is missing for the topic. Please specify subscription_name." + ) entity_name = queue_name or topic_name if not entity_name: - raise ValueError("Queue/Topic name is missing. Please specify queue_name/topic_name.") + raise ValueError( + "Queue/Topic name is missing. Please specify queue_name/topic_name." + ) super(ServiceBusReceiver, self).__init__( fully_qualified_namespace=fully_qualified_namespace, @@ -209,13 +228,19 @@ def __init__( prefetch_count=prefetch_count, **kwargs, ) - self._session = None if self._session_id is None else ServiceBusSession(cast(str, self._session_id), self) + self._session = ( + None + if self._session_id is None + else ServiceBusSession(cast(str, self._session_id), self) + ) self._receive_context = threading.Event() self._handler: Union["pyamqp_ReceiveClientSync", "uamqp_ReceiveClientSync"] self._build_received_message = functools.partial( self._amqp_transport.build_received_message, self, ServiceBusReceivedMessage ) - self._iter_contextual_wrapper = functools.partial(self._amqp_transport.iter_contextual_wrapper, self) + self._iter_contextual_wrapper = functools.partial( + self._amqp_transport.iter_contextual_wrapper, self + ) self._iter_next = functools.partial(self._amqp_transport.iter_next, self) def __enter__(self) -> "ServiceBusReceiver": @@ -230,12 +255,16 @@ def __enter__(self) -> "ServiceBusReceiver": def __iter__(self) -> Iterator["ServiceBusReceivedMessage"]: return self._iter_contextual_wrapper() - def _inner_next(self, wait_time: Optional[float] = None) -> "ServiceBusReceivedMessage": + def _inner_next( + self, wait_time: Optional[float] = None + ) -> "ServiceBusReceivedMessage": # We do this weird wrapping such that an imperitive next() call, and a generator-based iter both trace sanely. self._check_live() while True: try: - return self._do_retryable_operation(self._iter_next, wait_time=wait_time) + return self._do_retryable_operation( + self._iter_next, wait_time=wait_time + ) except StopIteration: self._message_iter = None raise @@ -254,7 +283,11 @@ def __next__(self) -> ServiceBusReceivedMessage: next = __next__ # for python2.7 @classmethod - def _from_connection_string(cls, conn_str: str, **kwargs: Any) -> "ServiceBusReceiver": # pylint: disable=docstring-keyword-should-match-keyword-only + def _from_connection_string( + cls, conn_str: str, **kwargs: Any + ) -> ( + "ServiceBusReceiver" + ): # pylint: disable=docstring-keyword-should-match-keyword-only """Create a ServiceBusReceiver from a connection string. :param conn_str: The connection string of a Service Bus. @@ -318,10 +351,14 @@ def _from_connection_string(cls, conn_str: str, **kwargs: Any) -> "ServiceBusRec raise ValueError("Queue entity does not have subscription.") if kwargs.get("topic_name") and not kwargs.get("subscription_name"): - raise ValueError("Subscription name is missing for the topic. Please specify subscription_name.") + raise ValueError( + "Subscription name is missing for the topic. Please specify subscription_name." + ) return cls(**constructor_args) - def _create_handler(self, auth: Union["pyamqp_JWTTokenAuth", "uamqp_JWTTokenAuth"]) -> None: + def _create_handler( + self, auth: Union["pyamqp_JWTTokenAuth", "uamqp_JWTTokenAuth"] + ) -> None: self._handler = self._amqp_transport.create_receive_client( receiver=self, @@ -332,12 +369,18 @@ def _create_handler(self, auth: Union["pyamqp_JWTTokenAuth", "uamqp_JWTTokenAuth retry_policy=self._error_policy, client_name=self._name, receive_mode=self._receive_mode, - timeout=self._max_wait_time * self._amqp_transport.TIMEOUT_FACTOR if self._max_wait_time else 0, + timeout=( + self._max_wait_time * self._amqp_transport.TIMEOUT_FACTOR + if self._max_wait_time + else 0 + ), # set link_credit to at least 1 so that messages can be received link_credit=self._prefetch_count + 1, # If prefetch is "off", then keep_alive coroutine frequently listens on the connection for messages and # releases right away, since no "prefetched" messages should be in the internal buffer. - keep_alive_interval=self._config.keep_alive if self._prefetch_count != 0 else 5, + keep_alive_interval=( + self._config.keep_alive if self._prefetch_count != 0 else 5 + ), shutdown_after_timeout=False, link_properties={CONSUMER_IDENTIFIER: self._name}, ) @@ -346,11 +389,16 @@ def _create_handler(self, auth: Union["pyamqp_JWTTokenAuth", "uamqp_JWTTokenAuth # If RECEIVE_AND_DELETE mode, messages are settled and removed from the Service Bus entity immediately, # so the regular _message_received callback should be used. This will ensure that all messages are added # to the internal buffer since they cannot be re-received, even if not received during an active receive call. - if self._prefetch_count == 0 and self._receive_mode == ServiceBusReceiveMode.PEEK_LOCK: + if ( + self._prefetch_count == 0 + and self._receive_mode == ServiceBusReceiveMode.PEEK_LOCK + ): # pylint: disable=protected-access - self._handler._message_received = functools.partial(self._amqp_transport.enhanced_message_received, self) + self._handler._message_received = functools.partial( + self._amqp_transport.enhanced_message_received, self + ) - def _open(self) -> None: + def _open(self, timeout: Optional[float] = None) -> None: # pylint: disable=protected-access if self._running: return @@ -360,8 +408,11 @@ def _open(self) -> None: auth = None if self._connection else create_authentication(self) self._create_handler(auth) try: + deadline = None if timeout is None else time.monotonic() + timeout self._handler.open(connection=self._connection) while not self._handler.client_ready(): + if deadline is not None and time.monotonic() >= deadline: + raise OperationTimeoutError() time.sleep(0.05) self._running = True except: @@ -371,6 +422,9 @@ def _open(self) -> None: if self._auto_lock_renewer and self._session: self._auto_lock_renewer.register(self, self.session) + def _open_with_timeout(self, timeout: float): + return self._open(timeout=timeout) + def _receive( self, max_message_count: Optional[int] = None, timeout: Optional[float] = None ) -> List[ServiceBusReceivedMessage]: @@ -388,39 +442,61 @@ def _receive( else 0 ) abs_timeout = ( - self._amqp_transport.get_current_time(amqp_receive_client) + timeout_time if (timeout_time) else 0 + self._amqp_transport.get_current_time(amqp_receive_client) + + timeout_time + if (timeout_time) + else 0 ) batch: Union[List["uamqp_Message"], List["pyamqp_Message"]] = [] - while not received_messages_queue.empty() and len(batch) < max_message_count: + while ( + not received_messages_queue.empty() and len(batch) < max_message_count + ): batch.append(received_messages_queue.get()) received_messages_queue.task_done() if len(batch) >= max_message_count: return [self._build_received_message(message) for message in batch] # Dynamically issue link credit if max_message_count >= 1 when the prefetch_count is the default value 0 - if max_message_count and self._prefetch_count == 0 and max_message_count >= 1: + if ( + max_message_count + and self._prefetch_count == 0 + and max_message_count >= 1 + ): link_credit_needed = max_message_count - len(batch) - self._amqp_transport.reset_link_credit(amqp_receive_client, link_credit_needed) + self._amqp_transport.reset_link_credit( + amqp_receive_client, link_credit_needed + ) first_message_received = expired = False receiving = True while receiving and not expired and len(batch) < max_message_count: while receiving and received_messages_queue.qsize() < max_message_count: - if abs_timeout and self._amqp_transport.get_current_time(amqp_receive_client) > abs_timeout: + if ( + abs_timeout + and self._amqp_transport.get_current_time(amqp_receive_client) + > abs_timeout + ): expired = True break before = received_messages_queue.qsize() receiving = amqp_receive_client.do_work() received = received_messages_queue.qsize() - before - if not first_message_received and received_messages_queue.qsize() > 0 and received > 0: + if ( + not first_message_received + and received_messages_queue.qsize() > 0 + and received > 0 + ): # first message(s) received, continue receiving for some time first_message_received = True abs_timeout = ( self._amqp_transport.get_current_time(amqp_receive_client) + self._further_pull_receive_timeout ) - while not received_messages_queue.empty() and len(batch) < max_message_count: + while ( + not received_messages_queue.empty() + and len(batch) < max_message_count + ): batch.append(received_messages_queue.get()) received_messages_queue.task_done() return [self._build_received_message(message) for message in batch] @@ -437,7 +513,9 @@ def _settle_message_with_retry( # pylint: disable=protected-access self._check_live() if not isinstance(message, ServiceBusReceivedMessage): - raise TypeError("Parameter 'message' must be of type ServiceBusReceivedMessage") + raise TypeError( + "Parameter 'message' must be of type ServiceBusReceivedMessage" + ) self._check_message_alive(message, settle_operation) # The following condition check is a hot fix for settling a message received for non-session queue after @@ -492,7 +570,8 @@ def _settle_message( dead_letter_details = ( { MGMT_REQUEST_DEAD_LETTER_REASON: dead_letter_reason or "", - MGMT_REQUEST_DEAD_LETTER_ERROR_DESCRIPTION: dead_letter_error_description or "", + MGMT_REQUEST_DEAD_LETTER_ERROR_DESCRIPTION: dead_letter_error_description + or "", } if settle_operation == MESSAGE_DEAD_LETTER else None @@ -518,7 +597,9 @@ def _settle_message_via_mgmt_link( ) -> Any: message = { MGMT_REQUEST_DISPOSITION_STATUS: settlement, - MGMT_REQUEST_LOCK_TOKENS: self._amqp_transport.AMQP_ARRAY_VALUE(lock_tokens), + MGMT_REQUEST_LOCK_TOKENS: self._amqp_transport.AMQP_ARRAY_VALUE( + lock_tokens + ), } self._populate_message_properties(message) @@ -526,11 +607,15 @@ def _settle_message_via_mgmt_link( message.update(dead_letter_details) # We don't do retry here, retry is done in the ServiceBusReceivedMessage._settle_message - return self._mgmt_request_response(REQUEST_RESPONSE_UPDATE_DISPOSTION_OPERATION, message, mgmt_handlers.default) + return self._mgmt_request_response( + REQUEST_RESPONSE_UPDATE_DISPOSTION_OPERATION, message, mgmt_handlers.default + ) def _renew_locks(self, *lock_tokens: str, **kwargs: Any) -> Any: timeout = kwargs.pop("timeout", None) - message = {MGMT_REQUEST_LOCK_TOKENS: self._amqp_transport.AMQP_ARRAY_VALUE(lock_tokens)} + message = { + MGMT_REQUEST_LOCK_TOKENS: self._amqp_transport.AMQP_ARRAY_VALUE(lock_tokens) + } return self._mgmt_request_response_with_retry( REQUEST_RESPONSE_RENEWLOCK_OPERATION, message, @@ -576,7 +661,9 @@ def close(self) -> None: super(ServiceBusReceiver, self).close() self._message_iter = None - def _get_streaming_message_iter(self, max_wait_time: Optional[float] = None) -> Iterator[ServiceBusReceivedMessage]: + def _get_streaming_message_iter( + self, max_wait_time: Optional[float] = None + ) -> Iterator[ServiceBusReceivedMessage]: """Receive messages from an iterator indefinitely, or if a max_wait_time is specified, until such a timeout occurs. @@ -711,7 +798,9 @@ def receive_deferred_messages( if len(sequence_numbers) == 0: return [] # no-op on empty list. self._open() - amqp_receive_mode = self._amqp_transport.ServiceBusToAMQPReceiveModeMap[self._receive_mode] + amqp_receive_mode = self._amqp_transport.ServiceBusToAMQPReceiveModeMap[ + self._receive_mode + ] try: receive_mode = cast(Enum, amqp_receive_mode).value except AttributeError: @@ -720,7 +809,9 @@ def receive_deferred_messages( MGMT_REQUEST_SEQUENCE_NUMBERS: self._amqp_transport.AMQP_ARRAY_VALUE( [self._amqp_transport.AMQP_LONG_VALUE(s) for s in sequence_numbers] ), - MGMT_REQUEST_RECEIVER_SETTLE_MODE: self._amqp_transport.AMQP_UINT_VALUE(receive_mode), + MGMT_REQUEST_RECEIVER_SETTLE_MODE: self._amqp_transport.AMQP_UINT_VALUE( + receive_mode + ), } self._populate_message_properties(message) @@ -740,7 +831,10 @@ def receive_deferred_messages( ) links = get_receive_links(messages) with receive_trace_context_manager( - self, span_name=SPAN_NAME_RECEIVE_DEFERRED, links=links, start_time=start_time + self, + span_name=SPAN_NAME_RECEIVE_DEFERRED, + links=links, + start_time=start_time, ): if ( self._auto_lock_renewer @@ -751,6 +845,117 @@ def receive_deferred_messages( self._auto_lock_renewer.register(self, message) return messages + def delete_messages( + self, + message_count: int, + *, + before_enqueued_time: Optional[datetime.datetime] = None, + timeout: Optional[float] = None, + ) -> DeleteMessagesResult: + """Permanently delete up to the requested number of eligible messages. + + Large messages can cause the service to delete fewer messages than requested. Locked, deferred, + and scheduled messages are not eligible. Currently, batch delete is not supported when partitioning + is enabled. + A dispatched request is not automatically retried. After an error, cancellation, or timeout, + the deletion outcome is unknown and no count is available. + + :param int message_count: The positive 32-bit maximum number of messages to delete. The service limit + is 500 for Basic and Standard and 4,000 for Premium. + :keyword Optional[datetime.datetime] before_enqueued_time: Only messages enqueued before this UTC time + can be deleted. The operation start time is used when omitted. + :keyword Optional[float] timeout: The operation timeout in seconds. The value must be greater than 0 + if specified. + :returns: The delete result containing the number of messages actually deleted. + :rtype: ~azure.servicebus.DeleteMessagesResult + """ + self._check_live() + if isinstance(message_count, bool) or not isinstance(message_count, int): + raise TypeError("The message_count must be an integer.") + if not 1 <= message_count <= 2_147_483_647: + raise ValueError("The message_count must be between 1 and 2147483647.") + if timeout is not None and timeout <= 0: + raise ValueError("The timeout must be greater than 0.") + + cutoff = before_enqueued_time or datetime.datetime.now(datetime.timezone.utc) + message = { + MGMT_REQUEST_MESSAGE_COUNT: self._amqp_transport.AMQP_INT_VALUE( + message_count + ), + MGMT_REQUEST_ENQUEUED_TIME_UTC: self._amqp_transport.AMQP_TIMESTAMP_VALUE( + datetime_to_timestamp_ms(cutoff) + ), + } + start_time = time.monotonic() + self._open_with_retry(timeout=timeout) + remaining_timeout = ( + None if timeout is None else timeout - (time.monotonic() - start_time) + ) + if remaining_timeout is not None and remaining_timeout <= 0: + raise OperationTimeoutError() + self._populate_message_properties(message) + deleted_count = self._mgmt_request_response( + REQUEST_RESPONSE_BATCH_DELETE_MESSAGES_OPERATION, + message, + functools.partial( + mgmt_handlers.batch_delete_op, max_message_count=message_count + ), + timeout=remaining_timeout, + ) + return DeleteMessagesResult(deleted_count) + + def purge_messages( + self, + *, + before_enqueued_time: Optional[datetime.datetime] = None, + max_message_count_per_batch: int = 500, + timeout: Optional[float] = None, + ) -> PurgeMessagesResult: + """Permanently delete eligible messages enqueued before the purge started or configured time. + + The enqueue-time threshold stays unchanged for every request, so newer messages remain. Large messages + can produce smaller batches, which purge continues processing. Locked, deferred, and scheduled messages + remain. Currently, purge is not supported when partitioning is enabled. + If an error, cancellation, or timeout occurs after dispatch, the purge can be partial and its exact + deletion outcome is unknown. + + :keyword Optional[datetime.datetime] before_enqueued_time: Only messages enqueued before this UTC time + can be deleted. The purge start time is used when omitted. + :keyword int max_message_count_per_batch: The maximum number of messages requested in + each batch-delete call. The default is 500. The service limit is 500 for Basic and Standard and 4,000 + for Premium. + :keyword Optional[float] timeout: The timeout in seconds for the entire purge operation. The value must + be greater than 0 if specified. + :returns: The purge result containing the total number of messages actually deleted. + :rtype: ~azure.servicebus.PurgeMessagesResult + """ + if isinstance(max_message_count_per_batch, bool) or not isinstance( + max_message_count_per_batch, int + ): + raise TypeError("The max_message_count_per_batch must be an integer.") + if not 1 <= max_message_count_per_batch <= 2_147_483_647: + raise ValueError( + "The max_message_count_per_batch must be between 1 and 2147483647." + ) + if timeout is not None and timeout <= 0: + raise ValueError("The timeout must be greater than 0.") + + cutoff = before_enqueued_time or datetime.datetime.now(datetime.timezone.utc) + deadline = None if timeout is None else time.monotonic() + timeout + deleted_count = 0 + while True: + remaining_timeout = None if deadline is None else deadline - time.monotonic() + if remaining_timeout is not None and remaining_timeout <= 0: + raise OperationTimeoutError() + result = self.delete_messages( + max_message_count_per_batch, + before_enqueued_time=cutoff, + timeout=remaining_timeout, + ) + deleted_count += result.deleted_message_count + if result.deleted_message_count == 0: + return PurgeMessagesResult(deleted_count) + def peek_messages( self, max_message_count: int = 1, @@ -798,18 +1003,24 @@ def peek_messages( self._open() message = { - MGMT_REQUEST_FROM_SEQUENCE_NUMBER: self._amqp_transport.AMQP_LONG_VALUE(sequence_number), + MGMT_REQUEST_FROM_SEQUENCE_NUMBER: self._amqp_transport.AMQP_LONG_VALUE( + sequence_number + ), MGMT_REQUEST_MAX_MESSAGE_COUNT: max_message_count, } self._populate_message_properties(message) - handler = functools.partial(mgmt_handlers.peek_op, receiver=self, amqp_transport=self._amqp_transport) + handler = functools.partial( + mgmt_handlers.peek_op, receiver=self, amqp_transport=self._amqp_transport + ) start_time = time.time_ns() messages = self._mgmt_request_response_with_retry( REQUEST_RESPONSE_PEEK_OPERATION, message, handler, timeout=timeout ) links = get_receive_links(messages) - with receive_trace_context_manager(self, span_name=SPAN_NAME_PEEK, links=links, start_time=start_time): + with receive_trace_context_manager( + self, span_name=SPAN_NAME_PEEK, links=links, start_time=start_time + ): return messages def complete_message(self, message: ServiceBusReceivedMessage) -> None: @@ -983,7 +1194,9 @@ def renew_message_lock( raise ValueError("The timeout must be greater than 0.") expiry = self._renew_locks(token, timeout=timeout) # type: ignore - message._expiry = utc_from_timestamp(expiry[MGMT_RESPONSE_MESSAGE_EXPIRATION][0] / 1000.0) + message._expiry = utc_from_timestamp( + expiry[MGMT_RESPONSE_MESSAGE_EXPIRATION][0] / 1000.0 + ) return message._expiry # type: ignore @@ -997,4 +1210,6 @@ def client_identifier(self) -> str: return self._name def __str__(self) -> str: - return f"Receiver client id: {self.client_identifier}, entity: {self.entity_path}" + return ( + f"Receiver client id: {self.client_identifier}, entity: {self.entity_path}" + ) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py index 12addc4d3dbb..3a3f10d4111e 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py @@ -394,8 +394,23 @@ async def _mgmt_request_response_with_retry( async def _open(self): raise ValueError("Subclass should override the method.") - async def _open_with_retry(self): - return await self._do_retryable_operation(self._open) + async def _open_with_timeout(self, timeout: float): + del timeout + return await self._open() + + async def _open_with_retry(self, timeout: Optional[float] = None): + async def open_with_timeout(timeout: Optional[float] = None): + if timeout is not None and timeout <= 0: + raise OperationTimeoutError() + if timeout is None: + return await self._open() + return await self._open_with_timeout(timeout) + + return await self._do_retryable_operation( + open_with_timeout, + timeout=timeout, + operation_requires_timeout=timeout is not None, + ) async def _close_handler(self): if self._handler: diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py index 0d7ea3b134a1..3149b96a2cec 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py @@ -10,12 +10,21 @@ import time import warnings from enum import Enum -from typing import Any, List, Optional, AsyncIterator as AsyncIteratorType, Union, TYPE_CHECKING, cast +from typing import ( + Any, + List, + Optional, + AsyncIterator as AsyncIteratorType, + Union, + TYPE_CHECKING, + cast, +) -from ..exceptions import MessageLockLostError +from ..exceptions import MessageLockLostError, OperationTimeoutError from ._servicebus_session_async import ServiceBusSession from ._base_handler_async import BaseHandler from .._common.message import ServiceBusReceivedMessage +from .._models import DeleteMessagesResult, PurgeMessagesResult from .._common.receiver_mixins import ReceiverMixin from .._common.constants import ( CONSUMER_IDENTIFIER, @@ -23,6 +32,7 @@ REQUEST_RESPONSE_PEEK_OPERATION, REQUEST_RESPONSE_RECEIVE_BY_SEQUENCE_NUMBER, REQUEST_RESPONSE_RENEWLOCK_OPERATION, + REQUEST_RESPONSE_BATCH_DELETE_MESSAGES_OPERATION, ServiceBusReceiveMode, MGMT_REQUEST_DISPOSITION_STATUS, MGMT_REQUEST_LOCK_TOKENS, @@ -30,6 +40,8 @@ MGMT_REQUEST_RECEIVER_SETTLE_MODE, MGMT_REQUEST_FROM_SEQUENCE_NUMBER, MGMT_REQUEST_MAX_MESSAGE_COUNT, + MGMT_REQUEST_MESSAGE_COUNT, + MGMT_REQUEST_ENQUEUED_TIME_UTC, MESSAGE_COMPLETE, MESSAGE_DEAD_LETTER, MESSAGE_ABANDON, @@ -41,7 +53,7 @@ MGMT_RESPONSE_MESSAGE_EXPIRATION, ) from .._common import mgmt_handlers -from .._common.utils import utc_from_timestamp +from .._common.utils import datetime_to_timestamp_ms, utc_from_timestamp from .._common.tracing import ( receive_trace_context_manager, settle_trace_context_manager, @@ -54,7 +66,9 @@ if TYPE_CHECKING: try: - from uamqp.async_ops.client_async import ReceiveClientAsync as uamqp_ReceiveClientAsync + from uamqp.async_ops.client_async import ( + ReceiveClientAsync as uamqp_ReceiveClientAsync, + ) from uamqp.authentication import JWTTokenAsync as uamqp_JWTTokenAuthAsync from uamqp.message import Message as uamqp_Message except ImportError: @@ -62,7 +76,9 @@ from ._transport._base_async import AmqpTransportAsync from .._pyamqp.message import Message as pyamqp_Message from .._pyamqp.aio import ReceiveClientAsync as pyamqp_ReceiveClientAsync - from .._pyamqp.aio._authentication_async import JWTTokenAuthAsync as pyamqp_JWTTokenAuthAsync + from .._pyamqp.aio._authentication_async import ( + JWTTokenAuthAsync as pyamqp_JWTTokenAuthAsync, + ) from azure.core.credentials_async import AsyncTokenCredential from azure.core.credentials import AzureSasCredential, AzureNamedKeyCredential from ._async_auto_lock_renewer import AutoLockRenewer @@ -147,19 +163,25 @@ class ServiceBusReceiver(AsyncIterator, BaseHandler, ReceiverMixin): def __init__( self, fully_qualified_namespace: str, - credential: Union["AsyncTokenCredential", "AzureSasCredential", "AzureNamedKeyCredential"], + credential: Union[ + "AsyncTokenCredential", "AzureSasCredential", "AzureNamedKeyCredential" + ], *, queue_name: Optional[str] = None, topic_name: Optional[str] = None, subscription_name: Optional[str] = None, - receive_mode: Union[ServiceBusReceiveMode, str] = ServiceBusReceiveMode.PEEK_LOCK, + receive_mode: Union[ + ServiceBusReceiveMode, str + ] = ServiceBusReceiveMode.PEEK_LOCK, max_wait_time: Optional[float] = None, auto_lock_renewer: Optional["AutoLockRenewer"] = None, prefetch_count: int = 0, **kwargs: Any, ) -> None: self._session_id = None - self._message_iter: Optional[AsyncIteratorType[Union["uamqp_Message", "pyamqp_Message"]]] = None + self._message_iter: Optional[ + AsyncIteratorType[Union["uamqp_Message", "pyamqp_Message"]] + ] = None self._amqp_transport: "AmqpTransportAsync" if kwargs.get("entity_name"): super(ServiceBusReceiver, self).__init__( @@ -176,11 +198,17 @@ def __init__( ) else: if queue_name and topic_name: - raise ValueError("Queue/Topic name can not be specified simultaneously.") + raise ValueError( + "Queue/Topic name can not be specified simultaneously." + ) if not (queue_name or topic_name): - raise ValueError("Queue/Topic name is missing. Please specify queue_name/topic_name.") + raise ValueError( + "Queue/Topic name is missing. Please specify queue_name/topic_name." + ) if topic_name and not subscription_name: - raise ValueError("Subscription name is missing for the topic. Please specify subscription_name.") + raise ValueError( + "Subscription name is missing for the topic. Please specify subscription_name." + ) entity_name = queue_name or topic_name @@ -208,14 +236,20 @@ def __init__( prefetch_count=prefetch_count, **kwargs, ) - self._session = None if self._session_id is None else ServiceBusSession(cast(str, self._session_id), self) + self._session = ( + None + if self._session_id is None + else ServiceBusSession(cast(str, self._session_id), self) + ) self._receive_context = asyncio.Event() self._handler: Union["pyamqp_ReceiveClientAsync", "uamqp_ReceiveClientAsync"] self._build_received_message = functools.partial( self._amqp_transport.build_received_message, self, ServiceBusReceivedMessage ) - self._iter_contextual_wrapper = functools.partial(self._amqp_transport.iter_contextual_wrapper_async, self) + self._iter_contextual_wrapper = functools.partial( + self._amqp_transport.iter_contextual_wrapper_async, self + ) self._iter_next = functools.partial(self._amqp_transport.iter_next_async, self) async def __aenter__(self) -> "ServiceBusReceiver": @@ -229,12 +263,16 @@ async def __aenter__(self) -> "ServiceBusReceiver": def __aiter__(self) -> AsyncIteratorType[ServiceBusReceivedMessage]: return self._iter_contextual_wrapper() - async def _inner_anext(self, wait_time: Optional[float] = None) -> ServiceBusReceivedMessage: + async def _inner_anext( + self, wait_time: Optional[float] = None + ) -> ServiceBusReceivedMessage: # We do this weird wrapping such that an imperitive next() call, and a generator-based iter both trace sanely. self._check_live() while True: try: - return await self._do_retryable_operation(self._iter_next, wait_time=wait_time) + return await self._do_retryable_operation( + self._iter_next, wait_time=wait_time + ) except StopAsyncIteration: self._message_iter = None raise @@ -250,7 +288,11 @@ async def __anext__(self) -> ServiceBusReceivedMessage: self._receive_context.clear() @classmethod - def _from_connection_string(cls, conn_str: str, **kwargs: Any) -> "ServiceBusReceiver": # pylint: disable=docstring-keyword-should-match-keyword-only + def _from_connection_string( + cls, conn_str: str, **kwargs: Any + ) -> ( + "ServiceBusReceiver" + ): # pylint: disable=docstring-keyword-should-match-keyword-only """Create a ServiceBusReceiver from a connection string. :param str conn_str: The connection string of a Service Bus. @@ -313,10 +355,14 @@ def _from_connection_string(cls, conn_str: str, **kwargs: Any) -> "ServiceBusRec raise ValueError("Queue entity does not have subscription.") if kwargs.get("topic_name") and not kwargs.get("subscription_name"): - raise ValueError("Subscription name is missing for the topic. Please specify subscription_name.") + raise ValueError( + "Subscription name is missing for the topic. Please specify subscription_name." + ) return cls(**constructor_args) - def _create_handler(self, auth: Union["pyamqp_JWTTokenAuthAsync", "uamqp_JWTTokenAuthAsync"]) -> None: + def _create_handler( + self, auth: Union["pyamqp_JWTTokenAuthAsync", "uamqp_JWTTokenAuthAsync"] + ) -> None: self._handler = self._amqp_transport.create_receive_client_async( receiver=self, @@ -327,12 +373,18 @@ def _create_handler(self, auth: Union["pyamqp_JWTTokenAuthAsync", "uamqp_JWTToke retry_policy=self._error_policy, client_name=self._name, receive_mode=self._receive_mode, - timeout=self._max_wait_time * self._amqp_transport.TIMEOUT_FACTOR if self._max_wait_time else 0, + timeout=( + self._max_wait_time * self._amqp_transport.TIMEOUT_FACTOR + if self._max_wait_time + else 0 + ), # set link_credit to at least 1 so that messages can be received link_credit=self._prefetch_count + 1, # If prefetch is 0, then keep_alive coroutine frequently listens on the connection for messages and # releases right away, since no "prefetched" messages should be in the internal buffer. - keep_alive_interval=self._config.keep_alive if self._prefetch_count != 0 else 5, + keep_alive_interval=( + self._config.keep_alive if self._prefetch_count != 0 else 5 + ), shutdown_after_timeout=False, link_properties={CONSUMER_IDENTIFIER: self._name}, ) @@ -341,10 +393,13 @@ def _create_handler(self, auth: Union["pyamqp_JWTTokenAuthAsync", "uamqp_JWTToke # If RECEIVE_AND_DELETE mode, messages are settled and removed from the Service Bus entity immediately, # so the regular _message_received callback should be used. This will ensure that all messages are added # to the internal buffer since they cannot be re-received, even if not received during an active receive call. - if self._prefetch_count == 0 and self._receive_mode == ServiceBusReceiveMode.PEEK_LOCK: + if ( + self._prefetch_count == 0 + and self._receive_mode == ServiceBusReceiveMode.PEEK_LOCK + ): self._amqp_transport.set_handler_message_received_async(self) - async def _open(self) -> None: + async def _open(self, timeout: Optional[float] = None) -> None: # pylint: disable=protected-access if self._running: return @@ -353,8 +408,11 @@ async def _open(self) -> None: auth = None if self._connection else (await create_authentication(self)) self._create_handler(auth) try: + deadline = None if timeout is None else time.monotonic() + timeout await self._handler.open_async(connection=self._connection) while not await self._handler.client_ready_async(): + if deadline is not None and time.monotonic() >= deadline: + raise OperationTimeoutError() await asyncio.sleep(0.05) self._running = True except: @@ -364,6 +422,9 @@ async def _open(self) -> None: if self._auto_lock_renewer and self._session: self._auto_lock_renewer.register(self, self.session) + async def _open_with_timeout(self, timeout: float): + return await self._open(timeout=timeout) + async def _receive( self, max_message_count: Optional[int] = None, timeout: Optional[float] = None ) -> List[ServiceBusReceivedMessage]: @@ -381,40 +442,62 @@ async def _receive( else 0 ) abs_timeout = ( - self._amqp_transport.get_current_time(amqp_receive_client) + timeout_seconds if timeout_seconds else 0 + self._amqp_transport.get_current_time(amqp_receive_client) + + timeout_seconds + if timeout_seconds + else 0 ) batch: Union[List["uamqp_Message"], List["pyamqp_Message"]] = [] - while not received_messages_queue.empty() and len(batch) < max_message_count: + while ( + not received_messages_queue.empty() and len(batch) < max_message_count + ): batch.append(received_messages_queue.get()) received_messages_queue.task_done() if len(batch) >= max_message_count: return [self._build_received_message(message) for message in batch] # Dynamically issue link credit if max_message_count >= 1 when the prefetch_count is the default value 0 - if max_message_count and self._prefetch_count == 0 and max_message_count >= 1: + if ( + max_message_count + and self._prefetch_count == 0 + and max_message_count >= 1 + ): link_credit_needed = max_message_count - len(batch) - await self._amqp_transport.reset_link_credit_async(amqp_receive_client, link_credit_needed) + await self._amqp_transport.reset_link_credit_async( + amqp_receive_client, link_credit_needed + ) first_message_received = expired = False receiving = True while receiving and not expired and len(batch) < max_message_count: while receiving and received_messages_queue.qsize() < max_message_count: - if abs_timeout and self._amqp_transport.get_current_time(amqp_receive_client) > abs_timeout: + if ( + abs_timeout + and self._amqp_transport.get_current_time(amqp_receive_client) + > abs_timeout + ): expired = True break before = received_messages_queue.qsize() receiving = await amqp_receive_client.do_work_async() received = received_messages_queue.qsize() - before - if not first_message_received and received_messages_queue.qsize() > 0 and received > 0: + if ( + not first_message_received + and received_messages_queue.qsize() > 0 + and received > 0 + ): # first message(s) received, continue receiving for some time first_message_received = True abs_timeout = ( self._amqp_transport.get_current_time(amqp_receive_client) + self._further_pull_receive_timeout ) - while not received_messages_queue.empty() and len(batch) < max_message_count: + while ( + not received_messages_queue.empty() + and len(batch) < max_message_count + ): batch.append(received_messages_queue.get()) received_messages_queue.task_done() return [self._build_received_message(message) for message in batch] @@ -431,7 +514,9 @@ async def _settle_message_with_retry( # pylint: disable=protected-access self._check_live() if not isinstance(message, ServiceBusReceivedMessage): - raise TypeError("Parameter 'message' must be of type ServiceBusReceivedMessage") + raise TypeError( + "Parameter 'message' must be of type ServiceBusReceivedMessage" + ) self._check_message_alive(message, settle_operation) # The following condition check is a hot fix for settling a message received for non-session queue after @@ -486,7 +571,8 @@ async def _settle_message( # type: ignore dead_letter_details = ( { MGMT_REQUEST_DEAD_LETTER_REASON: dead_letter_reason or "", - MGMT_REQUEST_DEAD_LETTER_ERROR_DESCRIPTION: dead_letter_error_description or "", + MGMT_REQUEST_DEAD_LETTER_ERROR_DESCRIPTION: dead_letter_error_description + or "", } if settle_operation == MESSAGE_DEAD_LETTER else None @@ -504,10 +590,14 @@ async def _settle_message( # type: ignore ) raise - async def _settle_message_via_mgmt_link(self, settlement, lock_tokens, dead_letter_details=None): + async def _settle_message_via_mgmt_link( + self, settlement, lock_tokens, dead_letter_details=None + ): message = { MGMT_REQUEST_DISPOSITION_STATUS: settlement, - MGMT_REQUEST_LOCK_TOKENS: self._amqp_transport.AMQP_ARRAY_VALUE(lock_tokens), + MGMT_REQUEST_LOCK_TOKENS: self._amqp_transport.AMQP_ARRAY_VALUE( + lock_tokens + ), } self._populate_message_properties(message) @@ -518,8 +608,12 @@ async def _settle_message_via_mgmt_link(self, settlement, lock_tokens, dead_lett REQUEST_RESPONSE_UPDATE_DISPOSTION_OPERATION, message, mgmt_handlers.default ) - async def _renew_locks(self, *lock_tokens: str, timeout: Optional[float] = None) -> Any: - message = {MGMT_REQUEST_LOCK_TOKENS: self._amqp_transport.AMQP_ARRAY_VALUE(lock_tokens)} + async def _renew_locks( + self, *lock_tokens: str, timeout: Optional[float] = None + ) -> Any: + message = { + MGMT_REQUEST_LOCK_TOKENS: self._amqp_transport.AMQP_ARRAY_VALUE(lock_tokens) + } return await self._mgmt_request_response_with_retry( REQUEST_RESPONSE_RENEWLOCK_OPERATION, message, @@ -661,7 +755,11 @@ async def receive_messages( return messages async def receive_deferred_messages( - self, sequence_numbers: Union[int, List[int]], *, timeout: Optional[float] = None, **kwargs: Any + self, + sequence_numbers: Union[int, List[int]], + *, + timeout: Optional[float] = None, + **kwargs: Any, ) -> List[ServiceBusReceivedMessage]: """Receive messages that have previously been deferred. @@ -696,7 +794,9 @@ async def receive_deferred_messages( if len(sequence_numbers) == 0: return [] # no-op on empty list. await self._open() - uamqp_receive_mode = self._amqp_transport.ServiceBusToAMQPReceiveModeMap[self._receive_mode] + uamqp_receive_mode = self._amqp_transport.ServiceBusToAMQPReceiveModeMap[ + self._receive_mode + ] try: receive_mode = cast(Enum, uamqp_receive_mode).value except AttributeError: @@ -705,7 +805,9 @@ async def receive_deferred_messages( MGMT_REQUEST_SEQUENCE_NUMBERS: self._amqp_transport.AMQP_ARRAY_VALUE( [self._amqp_transport.AMQP_LONG_VALUE(s) for s in sequence_numbers] ), - MGMT_REQUEST_RECEIVER_SETTLE_MODE: self._amqp_transport.AMQP_UINT_VALUE(receive_mode), + MGMT_REQUEST_RECEIVER_SETTLE_MODE: self._amqp_transport.AMQP_UINT_VALUE( + receive_mode + ), } self._populate_message_properties(message) @@ -725,7 +827,10 @@ async def receive_deferred_messages( ) links = get_receive_links(messages) with receive_trace_context_manager( - self, span_name=SPAN_NAME_RECEIVE_DEFERRED, links=links, start_time=start_time + self, + span_name=SPAN_NAME_RECEIVE_DEFERRED, + links=links, + start_time=start_time, ): if ( self._auto_lock_renewer @@ -736,8 +841,123 @@ async def receive_deferred_messages( self._auto_lock_renewer.register(self, message) return messages + async def delete_messages( + self, + message_count: int, + *, + before_enqueued_time: Optional[datetime.datetime] = None, + timeout: Optional[float] = None, + ) -> DeleteMessagesResult: + """Permanently delete up to the requested number of eligible messages. + + Large messages can cause the service to delete fewer messages than requested. Locked, deferred, and + scheduled messages are not eligible. Currently, batch delete is not supported when partitioning is + enabled. A dispatched request is not automatically retried; an error, cancellation, or timeout leaves + an unknown deletion outcome. + + :param int message_count: The positive 32-bit maximum number of messages to delete. The service limit + is 500 for Basic and Standard and 4,000 for Premium. + :keyword Optional[datetime.datetime] before_enqueued_time: Only messages enqueued before this UTC time + can be deleted. The operation start time is used when omitted. + :keyword Optional[float] timeout: The operation timeout in seconds. The value must be greater than 0 + if specified. + :returns: The delete result containing the number of messages actually deleted. + :rtype: ~azure.servicebus.DeleteMessagesResult + """ + self._check_live() + if isinstance(message_count, bool) or not isinstance(message_count, int): + raise TypeError("The message_count must be an integer.") + if not 1 <= message_count <= 2_147_483_647: + raise ValueError("The message_count must be between 1 and 2147483647.") + if timeout is not None and timeout <= 0: + raise ValueError("The timeout must be greater than 0.") + + cutoff = before_enqueued_time or datetime.datetime.now(datetime.timezone.utc) + message = { + MGMT_REQUEST_MESSAGE_COUNT: self._amqp_transport.AMQP_INT_VALUE( + message_count + ), + MGMT_REQUEST_ENQUEUED_TIME_UTC: self._amqp_transport.AMQP_TIMESTAMP_VALUE( + datetime_to_timestamp_ms(cutoff) + ), + } + start_time = time.monotonic() + await self._open_with_retry(timeout=timeout) + remaining_timeout = ( + None if timeout is None else timeout - (time.monotonic() - start_time) + ) + if remaining_timeout is not None and remaining_timeout <= 0: + raise OperationTimeoutError() + self._populate_message_properties(message) + deleted_count = await self._mgmt_request_response( + REQUEST_RESPONSE_BATCH_DELETE_MESSAGES_OPERATION, + message, + functools.partial( + mgmt_handlers.batch_delete_op, max_message_count=message_count + ), + timeout=remaining_timeout, + ) + return DeleteMessagesResult(deleted_count) + + async def purge_messages( + self, + *, + before_enqueued_time: Optional[datetime.datetime] = None, + max_message_count_per_batch: int = 500, + timeout: Optional[float] = None, + ) -> PurgeMessagesResult: + """Permanently delete eligible messages enqueued before the purge started or configured time. + + The enqueue-time threshold stays unchanged for every request, so newer messages remain. Large messages + can produce smaller batches, which purge continues processing. Locked, deferred, and scheduled messages + remain. Currently, purge is not supported when partitioning is enabled. + If an error, cancellation, or timeout occurs after dispatch, the purge can be partial and its exact + deletion outcome is unknown. + + :keyword Optional[datetime.datetime] before_enqueued_time: Only messages enqueued before this UTC time + can be deleted. The purge start time is used when omitted. + :keyword int max_message_count_per_batch: The maximum number of messages requested in + each batch-delete call. The default is 500. The service limit is 500 for Basic and Standard and 4,000 + for Premium. + :keyword Optional[float] timeout: The timeout in seconds for the entire purge operation. The value must + be greater than 0 if specified. + :returns: The purge result containing the total number of messages actually deleted. + :rtype: ~azure.servicebus.PurgeMessagesResult + """ + if isinstance(max_message_count_per_batch, bool) or not isinstance( + max_message_count_per_batch, int + ): + raise TypeError("The max_message_count_per_batch must be an integer.") + if not 1 <= max_message_count_per_batch <= 2_147_483_647: + raise ValueError( + "The max_message_count_per_batch must be between 1 and 2147483647." + ) + if timeout is not None and timeout <= 0: + raise ValueError("The timeout must be greater than 0.") + + cutoff = before_enqueued_time or datetime.datetime.now(datetime.timezone.utc) + deadline = None if timeout is None else time.monotonic() + timeout + deleted_count = 0 + while True: + remaining_timeout = None if deadline is None else deadline - time.monotonic() + if remaining_timeout is not None and remaining_timeout <= 0: + raise OperationTimeoutError() + result = await self.delete_messages( + max_message_count_per_batch, + before_enqueued_time=cutoff, + timeout=remaining_timeout, + ) + deleted_count += result.deleted_message_count + if result.deleted_message_count == 0: + return PurgeMessagesResult(deleted_count) + async def peek_messages( - self, max_message_count: int = 1, *, sequence_number: int = 0, timeout: Optional[float] = None, **kwargs: Any + self, + max_message_count: int = 1, + *, + sequence_number: int = 0, + timeout: Optional[float] = None, + **kwargs: Any, ) -> List[ServiceBusReceivedMessage]: """Browse messages currently pending in the queue. @@ -778,18 +998,24 @@ async def peek_messages( await self._open() message = { - MGMT_REQUEST_FROM_SEQUENCE_NUMBER: self._amqp_transport.AMQP_LONG_VALUE(sequence_number), + MGMT_REQUEST_FROM_SEQUENCE_NUMBER: self._amqp_transport.AMQP_LONG_VALUE( + sequence_number + ), MGMT_REQUEST_MAX_MESSAGE_COUNT: max_message_count, } self._populate_message_properties(message) - handler = functools.partial(mgmt_handlers.peek_op, receiver=self, amqp_transport=self._amqp_transport) + handler = functools.partial( + mgmt_handlers.peek_op, receiver=self, amqp_transport=self._amqp_transport + ) start_time = time.time_ns() messages = await self._mgmt_request_response_with_retry( REQUEST_RESPONSE_PEEK_OPERATION, message, handler, timeout=timeout ) links = get_receive_links(messages) - with receive_trace_context_manager(self, span_name=SPAN_NAME_PEEK, links=links, start_time=start_time): + with receive_trace_context_manager( + self, span_name=SPAN_NAME_PEEK, links=links, start_time=start_time + ): return messages async def complete_message(self, message: ServiceBusReceivedMessage) -> None: @@ -866,7 +1092,10 @@ async def defer_message(self, message: ServiceBusReceivedMessage) -> None: await self._settle_message_with_retry(message, MESSAGE_DEFER) async def dead_letter_message( - self, message: ServiceBusReceivedMessage, reason: Optional[str] = None, error_description: Optional[str] = None + self, + message: ServiceBusReceivedMessage, + reason: Optional[str] = None, + error_description: Optional[str] = None, ) -> None: """Move the message to the Dead Letter queue. @@ -901,7 +1130,11 @@ async def dead_letter_message( ) async def renew_message_lock( - self, message: ServiceBusReceivedMessage, *, timeout: Optional[float] = None, **kwargs: Any + self, + message: ServiceBusReceivedMessage, + *, + timeout: Optional[float] = None, + **kwargs: Any, ) -> datetime.datetime: # pylint: disable=protected-access """Renew the message lock. @@ -970,4 +1203,6 @@ def client_identifier(self) -> str: return self._name def __str__(self) -> str: - return f"Receiver client id: {self.client_identifier}, entity: {self.entity_path}" + return ( + f"Receiver client id: {self.client_identifier}, entity: {self.entity_path}" + ) diff --git a/sdk/servicebus/azure-servicebus/samples/async_samples/sample_code_servicebus_async.py b/sdk/servicebus/azure-servicebus/samples/async_samples/sample_code_servicebus_async.py index e5d44ccaee46..a47e9ab4bbfb 100644 --- a/sdk/servicebus/azure-servicebus/samples/async_samples/sample_code_servicebus_async.py +++ b/sdk/servicebus/azure-servicebus/samples/async_samples/sample_code_servicebus_async.py @@ -34,7 +34,9 @@ def example_create_servicebus_client_async(): from azure.servicebus.aio import ServiceBusClient servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] - servicebus_client = ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) + servicebus_client = ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) # [END create_sb_client_from_conn_str_async] # [START create_sb_client_async] @@ -44,7 +46,8 @@ def example_create_servicebus_client_async(): fully_qualified_namespace = os.environ["SERVICEBUS_FULLY_QUALIFIED_NAMESPACE"] servicebus_client = ServiceBusClient( - fully_qualified_namespace=fully_qualified_namespace, credential=DefaultAzureCredential() + fully_qualified_namespace=fully_qualified_namespace, + credential=DefaultAzureCredential(), ) # [END create_sb_client_async] return servicebus_client @@ -58,7 +61,9 @@ async def example_create_servicebus_sender_async(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] queue_name = os.environ["SERVICEBUS_QUEUE_NAME"] - queue_sender = ServiceBusSender._from_connection_string(conn_str=servicebus_connection_str, queue_name=queue_name) + queue_sender = ServiceBusSender._from_connection_string( + conn_str=servicebus_connection_str, queue_name=queue_name + ) # [END create_servicebus_sender_from_conn_str_async] # [START create_servicebus_sender_from_sb_client_async] @@ -67,7 +72,9 @@ async def example_create_servicebus_sender_async(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] queue_name = os.environ["SERVICEBUS_QUEUE_NAME"] - servicebus_client = ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) + servicebus_client = ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) async with servicebus_client: queue_sender = servicebus_client.get_queue_sender(queue_name=queue_name) # [END create_servicebus_sender_from_sb_client_async] @@ -78,7 +85,9 @@ async def example_create_servicebus_sender_async(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] topic_name = os.environ["SERVICEBUS_TOPIC_NAME"] - servicebus_client = ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) + servicebus_client = ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) async with servicebus_client: topic_sender = servicebus_client.get_topic_sender(topic_name=topic_name) # [END create_topic_sender_from_sb_client_async] @@ -108,7 +117,9 @@ async def example_create_servicebus_receiver_async(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] queue_name = os.environ["SERVICEBUS_QUEUE_NAME"] - servicebus_client = ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) + servicebus_client = ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) async with servicebus_client: queue_receiver = servicebus_client.get_queue_receiver( queue_name=queue_name, sub_queue=ServiceBusSubQueue.DEAD_LETTER @@ -121,7 +132,9 @@ async def example_create_servicebus_receiver_async(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] queue_name = os.environ["SERVICEBUS_QUEUE_NAME"] - servicebus_client = ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) + servicebus_client = ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) async with servicebus_client: queue_receiver = servicebus_client.get_queue_receiver(queue_name=queue_name) # [END create_servicebus_receiver_from_sb_client_async] @@ -134,10 +147,14 @@ async def example_create_servicebus_receiver_async(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] topic_name = os.environ["SERVICEBUS_TOPIC_NAME"] subscription_name = os.environ["SERVICEBUS_SUBSCRIPTION_NAME"] - servicebus_client = ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) + servicebus_client = ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) async with servicebus_client: subscription_receiver = servicebus_client.get_subscription_receiver( - topic_name=topic_name, subscription_name=subscription_name, sub_queue=ServiceBusSubQueue.DEAD_LETTER + topic_name=topic_name, + subscription_name=subscription_name, + sub_queue=ServiceBusSubQueue.DEAD_LETTER, ) # [END create_subscription_deadletter_receiver_from_sb_client_async] @@ -148,7 +165,9 @@ async def example_create_servicebus_receiver_async(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] topic_name = os.environ["SERVICEBUS_TOPIC_NAME"] subscription_name = os.environ["SERVICEBUS_SUBSCRIPTION_NAME"] - servicebus_client = ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) + servicebus_client = ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) async with servicebus_client: subscription_receiver = servicebus_client.get_subscription_receiver( topic_name=topic_name, @@ -241,7 +260,9 @@ async def example_send_and_receive_async(): lock_renewal = AutoLockRenewer() async with servicebus_receiver: async for message in servicebus_receiver: - lock_renewal.register(servicebus_receiver, message, max_lock_renewal_duration=60) + lock_renewal.register( + servicebus_receiver, message, max_lock_renewal_duration=60 + ) await process_message(message) await servicebus_receiver.complete_message(message) # [END auto_lock_renew_message_async] @@ -278,21 +299,29 @@ async def example_receive_deadletter_async(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] queue_name = os.environ["SERVICEBUS_QUEUE_NAME"] - async with ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) as servicebus_client: + async with ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) as servicebus_client: async with servicebus_client.get_queue_sender(queue_name) as servicebus_sender: await servicebus_sender.send_messages(ServiceBusMessage("Hello World")) # [START receive_deadletter_async] - async with servicebus_client.get_queue_receiver(queue_name) as servicebus_receiver: + async with servicebus_client.get_queue_receiver( + queue_name + ) as servicebus_receiver: messages = await servicebus_receiver.receive_messages(max_wait_time=5) for message in messages: await servicebus_receiver.dead_letter_message( - message, reason="reason for dead lettering", error_description="description for dead lettering" + message, + reason="reason for dead lettering", + error_description="description for dead lettering", ) async with servicebus_client.get_queue_receiver( queue_name, sub_queue=ServiceBusSubQueue.DEAD_LETTER ) as servicebus_deadletter_receiver: - messages = await servicebus_deadletter_receiver.receive_messages(max_wait_time=5) + messages = await servicebus_deadletter_receiver.receive_messages( + max_wait_time=5 + ) for message in messages: await servicebus_deadletter_receiver.complete_message(message) # [END receive_deadletter_async] @@ -303,30 +332,40 @@ async def example_session_ops_async(): queue_name = os.environ["SERVICEBUS_SESSION_QUEUE_NAME"] session_id = "" - async with ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) as servicebus_client: + async with ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) as servicebus_client: async with servicebus_client.get_queue_sender(queue_name=queue_name) as sender: await sender.send_messages(ServiceBusMessage("msg", session_id=session_id)) # [START get_session_async] - async with servicebus_client.get_queue_receiver(queue_name=queue_name, session_id=session_id) as receiver: + async with servicebus_client.get_queue_receiver( + queue_name=queue_name, session_id=session_id + ) as receiver: session = receiver.session # [END get_session_async] # [START get_session_state_async] - async with servicebus_client.get_queue_receiver(queue_name=queue_name, session_id=session_id) as receiver: + async with servicebus_client.get_queue_receiver( + queue_name=queue_name, session_id=session_id + ) as receiver: session = receiver.session session_state = await session.get_state() # [END get_session_state_async] # [START set_session_state_async] - async with servicebus_client.get_queue_receiver(queue_name=queue_name, session_id=session_id) as receiver: + async with servicebus_client.get_queue_receiver( + queue_name=queue_name, session_id=session_id + ) as receiver: session = receiver.session await session.set_state("START") # [END set_session_state_async] # [START session_renew_lock_async] - async with servicebus_client.get_queue_receiver(queue_name=queue_name, session_id=session_id) as receiver: + async with servicebus_client.get_queue_receiver( + queue_name=queue_name, session_id=session_id + ) as receiver: session = receiver.session await session.renew_lock() # [END session_renew_lock_async] @@ -335,7 +374,9 @@ async def example_session_ops_async(): from azure.servicebus.aio import AutoLockRenewer lock_renewal = AutoLockRenewer() - async with servicebus_client.get_queue_receiver(queue_name=queue_name, session_id=session_id) as receiver: + async with servicebus_client.get_queue_receiver( + queue_name=queue_name, session_id=session_id + ) as receiver: session = receiver.session # Auto renew session lock for 2 minutes lock_renewal.register(receiver, session, max_lock_renewal_duration=120) @@ -346,13 +387,82 @@ async def example_session_ops_async(): break +async def example_delete_and_purge_messages_async(): + servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] + queue_name = os.environ["SERVICEBUS_QUEUE_NAME"] + + # [START delete_and_purge_messages_async] + async with ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) as servicebus_client: + async with servicebus_client.get_queue_receiver(queue_name) as receiver: + requested_count = 100 + delete_result = await receiver.delete_messages(requested_count) + # Any request can return fewer deletions than requested, especially when messages are large. + print( + f"Requested {requested_count}; the service deleted {delete_result.deleted_message_count}." + ) + + # The default purge uses 500-message batches. + purge_result = await receiver.purge_messages() + print( + f"The service purged {purge_result.deleted_message_count} remaining messages." + ) + # [END delete_and_purge_messages_async] + + +async def example_purge_messages_advanced_async(): + servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] + queue_name = os.environ["SERVICEBUS_QUEUE_NAME"] + + # [START purge_messages_advanced_async] + async with ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) as servicebus_client: + async with servicebus_client.get_queue_receiver(queue_name) as receiver: + enqueue_time_threshold = datetime.datetime.now(datetime.timezone.utc) + # Premium supports up to 4,000 messages per request. + result = await receiver.purge_messages( + before_enqueued_time=enqueue_time_threshold, + max_message_count_per_batch=4000, + ) + print( + f"Purged {result.deleted_message_count} messages enqueued before {enqueue_time_threshold}." + ) + + # If a destructive call raises after dispatch, its exact outcome can be unknown. + # Inspect your application state before deciding whether another purge is appropriate. + # [END purge_messages_advanced_async] + + +async def example_purge_messages_from_session_async(): + servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] + queue_name = os.environ["SERVICEBUS_SESSION_QUEUE_NAME"] + session_id = os.environ["SERVICEBUS_SESSION_ID"] + + # [START purge_messages_from_session_async] + async with ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) as servicebus_client: + async with servicebus_client.get_queue_receiver( + queue_name=queue_name, session_id=session_id + ) as session_receiver: + result = await session_receiver.purge_messages() + print( + f"Removed {result.deleted_message_count} messages from session {session_id}." + ) + # [END purge_messages_from_session_async] + + async def example_schedule_ops_async(): servicebus_sender = await example_create_servicebus_sender_async() # [START scheduling_messages_async] async with servicebus_sender: scheduled_time_utc = datetime.datetime.utcnow() + datetime.timedelta(seconds=30) scheduled_messages = [ServiceBusMessage("Scheduled message") for _ in range(10)] - sequence_nums = await servicebus_sender.schedule_messages(scheduled_messages, scheduled_time_utc) + sequence_nums = await servicebus_sender.schedule_messages( + scheduled_messages, scheduled_time_utc + ) # [END scheduling_messages_async] servicebus_sender = await example_create_servicebus_sender_async() @@ -368,3 +478,6 @@ async def example_schedule_ops_async(): asyncio.run(example_schedule_ops_async()) asyncio.run(example_receive_deadletter_async()) asyncio.run(example_session_ops_async()) + asyncio.run(example_delete_and_purge_messages_async()) + asyncio.run(example_purge_messages_advanced_async()) + asyncio.run(example_purge_messages_from_session_async()) diff --git a/sdk/servicebus/azure-servicebus/samples/sync_samples/sample_code_servicebus.py b/sdk/servicebus/azure-servicebus/samples/sync_samples/sample_code_servicebus.py index 9f3bd6a0aa8e..9e7a67d82ab3 100644 --- a/sdk/servicebus/azure-servicebus/samples/sync_samples/sample_code_servicebus.py +++ b/sdk/servicebus/azure-servicebus/samples/sync_samples/sample_code_servicebus.py @@ -30,7 +30,9 @@ def example_create_servicebus_client_sync(): from azure.servicebus import ServiceBusClient servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] - servicebus_client = ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) + servicebus_client = ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) # [END create_sb_client_from_conn_str_sync] # [START create_sb_client_sync] @@ -40,7 +42,8 @@ def example_create_servicebus_client_sync(): fully_qualified_namespace = os.environ["SERVICEBUS_FULLY_QUALIFIED_NAMESPACE"] servicebus_client = ServiceBusClient( - fully_qualified_namespace=fully_qualified_namespace, credential=DefaultAzureCredential() + fully_qualified_namespace=fully_qualified_namespace, + credential=DefaultAzureCredential(), ) # [END create_sb_client_sync] return servicebus_client @@ -54,7 +57,9 @@ def example_create_servicebus_sender_sync(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] queue_name = os.environ["SERVICEBUS_QUEUE_NAME"] - queue_sender = ServiceBusSender._from_connection_string(conn_str=servicebus_connection_str, queue_name=queue_name) + queue_sender = ServiceBusSender._from_connection_string( + conn_str=servicebus_connection_str, queue_name=queue_name + ) # [END create_servicebus_sender_from_conn_str_sync] # [START create_servicebus_sender_from_sb_client_sync] @@ -63,7 +68,9 @@ def example_create_servicebus_sender_sync(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] queue_name = os.environ["SERVICEBUS_QUEUE_NAME"] - servicebus_client = ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) + servicebus_client = ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) with servicebus_client: queue_sender = servicebus_client.get_queue_sender(queue_name=queue_name) # [END create_servicebus_sender_from_sb_client_sync] @@ -74,7 +81,9 @@ def example_create_servicebus_sender_sync(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] topic_name = os.environ["SERVICEBUS_TOPIC_NAME"] - servicebus_client = ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) + servicebus_client = ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) with servicebus_client: topic_sender = servicebus_client.get_topic_sender(topic_name=topic_name) # [END create_topic_sender_from_sb_client_sync] @@ -103,7 +112,9 @@ def example_create_servicebus_receiver_sync(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] queue_name = os.environ["SERVICEBUS_QUEUE_NAME"] - servicebus_client = ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) + servicebus_client = ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) with servicebus_client: queue_dlq_receiver = servicebus_client.get_queue_receiver( queue_name=queue_name, sub_queue=ServiceBusSubQueue.DEAD_LETTER @@ -116,7 +127,9 @@ def example_create_servicebus_receiver_sync(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] queue_name = os.environ["SERVICEBUS_QUEUE_NAME"] - servicebus_client = ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) + servicebus_client = ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) with servicebus_client: queue_receiver = servicebus_client.get_queue_receiver(queue_name=queue_name) # [END create_servicebus_receiver_from_sb_client_sync] @@ -128,7 +141,9 @@ def example_create_servicebus_receiver_sync(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] topic_name = os.environ["SERVICEBUS_TOPIC_NAME"] subscription_name = os.environ["SERVICEBUS_SUBSCRIPTION_NAME"] - servicebus_client = ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) + servicebus_client = ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) with servicebus_client: subscription_receiver = servicebus_client.get_subscription_receiver( topic_name=topic_name, @@ -143,10 +158,14 @@ def example_create_servicebus_receiver_sync(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] topic_name = os.environ["SERVICEBUS_TOPIC_NAME"] subscription_name = os.environ["SERVICEBUS_SUBSCRIPTION_NAME"] - servicebus_client = ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) + servicebus_client = ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) with servicebus_client: subscription_dlq_receiver = servicebus_client.get_subscription_receiver( - topic_name=topic_name, subscription_name=subscription_name, sub_queue=ServiceBusSubQueue.DEAD_LETTER + topic_name=topic_name, + subscription_name=subscription_name, + sub_queue=ServiceBusSubQueue.DEAD_LETTER, ) # [END create_subscription_deadletter_receiver_from_sb_client_sync] @@ -198,7 +217,9 @@ def example_send_and_receive_sync(): with servicebus_receiver: for message in servicebus_receiver: # Auto renew message for 1 minute. - lock_renewal.register(servicebus_receiver, message, max_lock_renewal_duration=60) + lock_renewal.register( + servicebus_receiver, message, max_lock_renewal_duration=60 + ) process_message(message) servicebus_receiver.complete_message(message) # [END auto_lock_renew_message_sync] @@ -217,12 +238,16 @@ def example_send_and_receive_sync(): from typing import List from azure.servicebus import ServiceBusReceivedMessage - messages_complex: List[ServiceBusReceivedMessage] = servicebus_receiver.receive_messages(max_wait_time=5) + messages_complex: List[ServiceBusReceivedMessage] = ( + servicebus_receiver.receive_messages(max_wait_time=5) + ) for message in messages_complex: print("Receiving: {}".format(message)) print("Time to live: {}".format(message.time_to_live)) print("Sequence number: {}".format(message.sequence_number)) - print("Enqueued Sequence number: {}".format(message.enqueued_sequence_number)) + print( + "Enqueued Sequence number: {}".format(message.enqueued_sequence_number) + ) print("Partition Key: {}".format(message.partition_key)) print("Application Properties: {}".format(message.application_properties)) print("Delivery count: {}".format(message.delivery_count)) @@ -301,7 +326,9 @@ def example_receive_deadletter_sync(): servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] queue_name = os.environ["SERVICEBUS_QUEUE_NAME"] - with ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) as servicebus_client: + with ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) as servicebus_client: with servicebus_client.get_queue_sender(queue_name) as servicebus_sender: servicebus_sender.send_messages(ServiceBusMessage("Hello World")) # [START receive_deadletter_sync] @@ -309,13 +336,17 @@ def example_receive_deadletter_sync(): messages = servicebus_receiver.receive_messages(max_wait_time=5) for message in messages: servicebus_receiver.dead_letter_message( - message, reason="reason for dead lettering", error_description="description for dead lettering" + message, + reason="reason for dead lettering", + error_description="description for dead lettering", ) with servicebus_client.get_queue_receiver( queue_name, sub_queue=ServiceBusSubQueue.DEAD_LETTER ) as servicebus_deadletter_receiver: - messages_deadletter = servicebus_deadletter_receiver.receive_messages(max_wait_time=5) + messages_deadletter = servicebus_deadletter_receiver.receive_messages( + max_wait_time=5 + ) for message in messages_deadletter: servicebus_deadletter_receiver.complete_message(message) # [END receive_deadletter_sync] @@ -326,30 +357,40 @@ def example_session_ops_sync(): queue_name = os.environ["SERVICEBUS_SESSION_QUEUE_NAME"] session_id = os.environ["SERVICEBUS_SESSION_ID"] - with ServiceBusClient.from_connection_string(conn_str=servicebus_connection_str) as servicebus_client: + with ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) as servicebus_client: with servicebus_client.get_queue_sender(queue_name=queue_name) as sender: sender.send_messages(ServiceBusMessage("msg", session_id=session_id)) # [START get_session_sync] - with servicebus_client.get_queue_receiver(queue_name=queue_name, session_id=session_id) as receiver: + with servicebus_client.get_queue_receiver( + queue_name=queue_name, session_id=session_id + ) as receiver: session = receiver.session # [END get_session_sync] # [START get_session_state_sync] - with servicebus_client.get_queue_receiver(queue_name=queue_name, session_id=session_id) as receiver: + with servicebus_client.get_queue_receiver( + queue_name=queue_name, session_id=session_id + ) as receiver: session = receiver.session session_state = session.get_state() # [END get_session_state_sync] # [START set_session_state_sync] - with servicebus_client.get_queue_receiver(queue_name=queue_name, session_id=session_id) as receiver: + with servicebus_client.get_queue_receiver( + queue_name=queue_name, session_id=session_id + ) as receiver: session = receiver.session session.set_state("START") # [END set_session_state_sync] # [START session_renew_lock_sync] - with servicebus_client.get_queue_receiver(queue_name=queue_name, session_id=session_id) as receiver: + with servicebus_client.get_queue_receiver( + queue_name=queue_name, session_id=session_id + ) as receiver: session = receiver.session session.renew_lock() # [END session_renew_lock_sync] @@ -358,7 +399,9 @@ def example_session_ops_sync(): from azure.servicebus import AutoLockRenewer lock_renewal = AutoLockRenewer(max_workers=4) - with servicebus_client.get_queue_receiver(queue_name=queue_name, session_id=session_id) as receiver: + with servicebus_client.get_queue_receiver( + queue_name=queue_name, session_id=session_id + ) as receiver: session = receiver.session # Auto renew session lock for 2 minutes lock_renewal.register(receiver, session, max_lock_renewal_duration=120) @@ -369,13 +412,82 @@ def example_session_ops_sync(): break +def example_delete_and_purge_messages_sync(): + servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] + queue_name = os.environ["SERVICEBUS_QUEUE_NAME"] + + # [START delete_and_purge_messages_sync] + with ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) as servicebus_client: + with servicebus_client.get_queue_receiver(queue_name) as receiver: + requested_count = 100 + delete_result = receiver.delete_messages(requested_count) + # Any request can return fewer deletions than requested, especially when messages are large. + print( + f"Requested {requested_count}; the service deleted {delete_result.deleted_message_count}." + ) + + # The default purge uses 500-message batches. + purge_result = receiver.purge_messages() + print( + f"The service purged {purge_result.deleted_message_count} remaining messages." + ) + # [END delete_and_purge_messages_sync] + + +def example_purge_messages_advanced_sync(): + servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] + queue_name = os.environ["SERVICEBUS_QUEUE_NAME"] + + # [START purge_messages_advanced_sync] + with ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) as servicebus_client: + with servicebus_client.get_queue_receiver(queue_name) as receiver: + enqueue_time_threshold = datetime.datetime.now(datetime.timezone.utc) + # Premium supports up to 4,000 messages per request. + result = receiver.purge_messages( + before_enqueued_time=enqueue_time_threshold, + max_message_count_per_batch=4000, + ) + print( + f"Purged {result.deleted_message_count} messages enqueued before {enqueue_time_threshold}." + ) + + # If a destructive call raises after dispatch, its exact outcome can be unknown. + # Inspect your application state before deciding whether another purge is appropriate. + # [END purge_messages_advanced_sync] + + +def example_purge_messages_from_session_sync(): + servicebus_connection_str = os.environ["SERVICEBUS_CONNECTION_STR"] + queue_name = os.environ["SERVICEBUS_SESSION_QUEUE_NAME"] + session_id = os.environ["SERVICEBUS_SESSION_ID"] + + # [START purge_messages_from_session_sync] + with ServiceBusClient.from_connection_string( + conn_str=servicebus_connection_str + ) as servicebus_client: + with servicebus_client.get_queue_receiver( + queue_name=queue_name, session_id=session_id + ) as session_receiver: + result = session_receiver.purge_messages() + print( + f"Removed {result.deleted_message_count} messages from session {session_id}." + ) + # [END purge_messages_from_session_sync] + + def example_schedule_ops_sync(): servicebus_sender = example_create_servicebus_sender_sync() # [START scheduling_messages] with servicebus_sender: scheduled_time_utc = datetime.datetime.utcnow() + datetime.timedelta(seconds=30) scheduled_messages = [ServiceBusMessage("Scheduled message") for _ in range(10)] - sequence_nums = servicebus_sender.schedule_messages(scheduled_messages, scheduled_time_utc) + sequence_nums = servicebus_sender.schedule_messages( + scheduled_messages, scheduled_time_utc + ) # [END scheduling_messages] servicebus_sender = example_create_servicebus_sender_sync() @@ -390,3 +502,6 @@ def example_schedule_ops_sync(): example_schedule_ops_sync() example_receive_deadletter_sync() example_session_ops_sync() +example_delete_and_purge_messages_sync() +example_purge_messages_advanced_sync() +example_purge_messages_from_session_sync() diff --git a/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py b/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py new file mode 100644 index 000000000000..888fb0596b4b --- /dev/null +++ b/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py @@ -0,0 +1,575 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from azure.servicebus import DeleteMessagesResult, ServiceBusReceiver +from azure.servicebus.aio import ServiceBusReceiver as AsyncServiceBusReceiver +from azure.servicebus._common.constants import ( + ERROR_CODE_MESSAGE_NOT_FOUND, + MGMT_REQUEST_ENQUEUED_TIME_UTC, + MGMT_REQUEST_MESSAGE_COUNT, + MGMT_REQUEST_SESSION_ID, + MGMT_RESPONSE_MESSAGE_ERROR_CONDITION, + REQUEST_RESPONSE_BATCH_DELETE_MESSAGES_OPERATION, + ServiceBusReceiveMode, +) +from azure.servicebus._common import mgmt_handlers +from azure.servicebus._pyamqp._encode import encode_payload +from azure.servicebus._transport._pyamqp_transport import PyamqpTransport +from azure.servicebus.exceptions import OperationTimeoutError + + +MAX_DELETE_MESSAGE_COUNT = 500 + + +def test_batch_delete_request_keys_encode_as_amqp_strings(): + message = PyamqpTransport.create_mgmt_msg( + message={ + MGMT_REQUEST_MESSAGE_COUNT: PyamqpTransport.AMQP_INT_VALUE(1), + MGMT_REQUEST_ENQUEUED_TIME_UTC: PyamqpTransport.AMQP_TIMESTAMP_VALUE(2), + }, + application_properties={}, + config=MagicMock(encoding="UTF-8"), + reply_to="queue/$management", + ) + encoded = bytearray() + encode_payload(encoded, message) + + assert b"\xa1\x0dmessage-count" in encoded + assert b"\xa1\x11enqueued-time-utc" in encoded + assert b"\xa0\x0dmessage-count" not in encoded + assert b"\xa0\x11enqueued-time-utc" not in encoded + + +def test_batch_delete_handler_returns_actual_count_and_only_maps_204_to_zero(): + message = MagicMock() + message.value = {b"message-count": 2} + message.application_properties = {} + transport = MagicMock() + + assert mgmt_handlers.batch_delete_op(200, message, None, transport, 10) == 2 + assert mgmt_handlers.batch_delete_op(204, message, None, transport, 10) == 0 + + mgmt_handlers.batch_delete_op(202, message, "unexpected", transport, 10) + transport.handle_amqp_mgmt_error.assert_called_once() + + +@pytest.mark.parametrize("deleted_count", [-1, 1.5, 11, True, None]) +def test_batch_delete_handler_rejects_malformed_count(deleted_count): + message = MagicMock() + message.value = {b"message-count": deleted_count} + message.application_properties = {} + + with pytest.raises(ValueError, match="valid message-count"): + mgmt_handlers.batch_delete_op(200, message, None, MagicMock(), 10) + + +def test_batch_delete_handler_maps_message_not_found_to_aggregate(): + message = MagicMock() + message.value = {b"message-count": 2} + message.application_properties = { + MGMT_RESPONSE_MESSAGE_ERROR_CONDITION: ERROR_CODE_MESSAGE_NOT_FOUND + } + + assert ( + mgmt_handlers.batch_delete_op(404, message, None, MagicMock(), 10) == 2 + ) + + +@pytest.mark.parametrize("value", [None, {}, {b"message-count": "2"}]) +def test_batch_delete_handler_rejects_message_not_found_without_valid_count(value): + message = MagicMock() + message.value = value + message.application_properties = { + MGMT_RESPONSE_MESSAGE_ERROR_CONDITION: ERROR_CODE_MESSAGE_NOT_FOUND + } + + with pytest.raises(ValueError, match="valid message-count"): + mgmt_handlers.batch_delete_op(404, message, None, MagicMock(), 10) + + +def _receiver(receiver_type, session_id=None): + receiver = object.__new__(receiver_type) + receiver._check_live = lambda: None + receiver._session_id = session_id + receiver._session = object() if session_id else None + receiver._handler = MagicMock() + receiver._amqp_transport = MagicMock() + receiver._amqp_transport.AMQP_INT_VALUE = lambda value: value + receiver._amqp_transport.AMQP_TIMESTAMP_VALUE = lambda value: value + receiver._open_with_retry = ( + AsyncMock() if receiver_type is AsyncServiceBusReceiver else MagicMock() + ) + receiver._mgmt_request_response_with_retry = MagicMock( + side_effect=AssertionError("destructive requests must not be retried") + ) + return receiver + + +class TestDeleteMessages: + def test_returns_actual_count_and_uses_one_shot_dispatch(self): + receiver = _receiver(ServiceBusReceiver) + cutoff = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc) + calls = [] + + def dispatch(operation, message, callback, **kwargs): + calls.append((operation, message, callback, kwargs)) + return 7 + + receiver._mgmt_request_response = dispatch + + result = receiver.delete_messages(10, before_enqueued_time=cutoff, timeout=12) + + assert result.deleted_message_count == 7 + assert len(calls) == 1 + assert calls[0][0] == REQUEST_RESPONSE_BATCH_DELETE_MESSAGES_OPERATION + assert calls[0][2].func is mgmt_handlers.batch_delete_op + assert calls[0][2].keywords["max_message_count"] == 10 + assert calls[0][1][MGMT_REQUEST_MESSAGE_COUNT] == 10 + assert calls[0][1][MGMT_REQUEST_ENQUEUED_TIME_UTC] == int( + cutoff.timestamp() * 1000 + ) + assert 0 < calls[0][3]["timeout"] <= 12 + receiver._open_with_retry.assert_called_once_with(timeout=12) + receiver._mgmt_request_response_with_retry.assert_not_called() + + def test_setup_failure_does_not_dispatch(self): + receiver = _receiver(ServiceBusReceiver) + receiver._open_with_retry.side_effect = RuntimeError("open failed") + receiver._mgmt_request_response = MagicMock() + + with pytest.raises(RuntimeError, match="open failed"): + receiver.delete_messages(1) + + receiver._mgmt_request_response.assert_not_called() + + def test_dispatch_failure_is_not_retried(self): + receiver = _receiver(ServiceBusReceiver) + receiver._mgmt_request_response = MagicMock( + side_effect=RuntimeError("dispatch failed") + ) + + with pytest.raises(RuntimeError, match="dispatch failed"): + receiver.delete_messages(1) + + receiver._open_with_retry.assert_called_once_with(timeout=None) + receiver._mgmt_request_response.assert_called_once() + receiver._mgmt_request_response_with_retry.assert_not_called() + + def test_dispatch_uses_remaining_timeout_and_stops_when_setup_exhausts_it(self): + receiver = _receiver(ServiceBusReceiver) + receiver._mgmt_request_response = MagicMock(return_value=1) + + with patch( + "azure.servicebus._servicebus_receiver.time.monotonic", + side_effect=[10.0, 11.5], + ): + receiver.delete_messages(1, timeout=2) + + assert receiver._mgmt_request_response.call_args.kwargs[ + "timeout" + ] == pytest.approx(0.5) + + receiver._mgmt_request_response.reset_mock() + with patch( + "azure.servicebus._servicebus_receiver.time.monotonic", + side_effect=[20.0, 21.0], + ): + with pytest.raises(OperationTimeoutError, match="Operation timed out"): + receiver.delete_messages(1, timeout=0.5) + + receiver._mgmt_request_response.assert_not_called() + + def test_forwards_session_id(self): + receiver = _receiver(ServiceBusReceiver, session_id="session-a") + captured = {} + + def dispatch(operation, message, callback, **kwargs): + captured.update(message) + return 1 + + receiver._mgmt_request_response = dispatch + + receiver.delete_messages(1) + + assert captured[MGMT_REQUEST_SESSION_ID] == "session-a" + + def test_populates_session_id_after_setup_resolves_next_session(self): + receiver = _receiver(ServiceBusReceiver, session_id="") + receiver._open_with_retry.side_effect = lambda **_: setattr( + receiver, "_session_id", "session-a" + ) + receiver._mgmt_request_response = MagicMock(return_value=1) + + receiver.delete_messages(1) + + message = receiver._mgmt_request_response.call_args.args[1] + assert message[MGMT_REQUEST_SESSION_ID] == "session-a" + + def test_open_readiness_respects_timeout(self): + receiver = _receiver(ServiceBusReceiver) + receiver._running = False + receiver._connection = None + receiver._auto_lock_renewer = None + receiver._receive_mode = ServiceBusReceiveMode.PEEK_LOCK + receiver._handler._shutdown = False + receiver._handler.client_ready.return_value = False + receiver._create_handler = MagicMock() + + with patch( + "azure.servicebus._servicebus_receiver.create_authentication", + return_value=None, + ), patch( + "azure.servicebus._servicebus_receiver.time.monotonic", + side_effect=[10.0, 11.0], + ), patch( + "azure.servicebus._servicebus_receiver.time.sleep" + ): + with pytest.raises(OperationTimeoutError): + receiver._open(timeout=0.5) + + def test_supports_premium_count(self): + receiver = _receiver(ServiceBusReceiver) + receiver._mgmt_request_response = MagicMock(return_value=4000) + + result = receiver.delete_messages(4000) + + assert result.deleted_message_count == 4000 + assert ( + receiver._mgmt_request_response.call_args.args[1][ + MGMT_REQUEST_MESSAGE_COUNT + ] + == 4000 + ) + + @pytest.mark.parametrize("message_count", [0, -1, 2_147_483_648]) + def test_rejects_counts_outside_service_limit(self, message_count): + receiver = _receiver(ServiceBusReceiver) + receiver._mgmt_request_response = MagicMock() + + with pytest.raises(ValueError): + receiver.delete_messages(message_count) + + receiver._mgmt_request_response.assert_not_called() + + @pytest.mark.parametrize("message_count", [True, 1.5, "1"]) + def test_rejects_non_integer_counts(self, message_count): + receiver = _receiver(ServiceBusReceiver) + receiver._mgmt_request_response = MagicMock() + + with pytest.raises(TypeError): + receiver.delete_messages(message_count) + + receiver._mgmt_request_response.assert_not_called() + + +class TestPurgeMessages: + def test_keeps_one_cutoff_and_stops_only_on_zero(self): + receiver = _receiver(ServiceBusReceiver) + counts = iter([500, 2, 0]) + calls = [] + + def dispatch(operation, message, callback, **kwargs): + calls.append((message.copy(), kwargs)) + return next(counts) + + receiver._mgmt_request_response = dispatch + + cutoff = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc) + result = receiver.purge_messages(before_enqueued_time=cutoff, timeout=18) + + assert result.deleted_message_count == 502 + assert len(calls) == 3 + assert [call[0][MGMT_REQUEST_MESSAGE_COUNT] for call in calls] == [ + 500, + 500, + 500, + ] + cutoffs = [call[0][MGMT_REQUEST_ENQUEUED_TIME_UTC] for call in calls] + assert cutoffs[0] == cutoffs[1] == cutoffs[2] + assert cutoffs[0] == int(cutoff.timestamp() * 1000) + assert all(0 < call[1]["timeout"] <= 18 for call in calls) + + def test_supports_premium_batch_size(self): + receiver = _receiver(ServiceBusReceiver) + counts = iter([4000, 2, 0]) + calls = [] + + def dispatch(operation, message, callback, **kwargs): + calls.append(message.copy()) + return next(counts) + + receiver._mgmt_request_response = dispatch + + result = receiver.purge_messages(max_message_count_per_batch=4000) + + assert result.deleted_message_count == 4002 + assert [call[MGMT_REQUEST_MESSAGE_COUNT] for call in calls] == [ + 4000, + 4000, + 4000, + ] + cutoffs = [call[MGMT_REQUEST_ENQUEUED_TIME_UTC] for call in calls] + assert cutoffs[0] == cutoffs[1] == cutoffs[2] + + def test_allows_service_to_enforce_batch_size(self): + receiver = _receiver(ServiceBusReceiver) + receiver._mgmt_request_response = MagicMock(return_value=0) + + receiver.purge_messages(max_message_count_per_batch=4001) + + assert receiver._mgmt_request_response.call_args.args[1][MGMT_REQUEST_MESSAGE_COUNT] == 4001 + + @pytest.mark.parametrize("message_count", [True, 0, 2_147_483_648]) + def test_rejects_invalid_batch_size(self, message_count): + receiver = _receiver(ServiceBusReceiver) + receiver._mgmt_request_response = MagicMock() + + with pytest.raises((TypeError, ValueError)): + receiver.purge_messages(max_message_count_per_batch=message_count) + + receiver._mgmt_request_response.assert_not_called() + + def test_uses_one_operation_deadline(self): + receiver = _receiver(ServiceBusReceiver) + receiver.delete_messages = MagicMock( + side_effect=[DeleteMessagesResult(1), DeleteMessagesResult(0)] + ) + + with patch( + "azure.servicebus._servicebus_receiver.time.monotonic", + side_effect=[10.0, 11.0, 12.0], + ): + result = receiver.purge_messages(timeout=5) + + assert result.deleted_message_count == 1 + assert [call.kwargs["timeout"] for call in receiver.delete_messages.call_args_list] == [4.0, 3.0] + + def test_operation_deadline_stops_before_another_dispatch(self): + receiver = _receiver(ServiceBusReceiver) + receiver.delete_messages = MagicMock(return_value=DeleteMessagesResult(1)) + + with patch( + "azure.servicebus._servicebus_receiver.time.monotonic", + side_effect=[20.0, 21.0, 25.1], + ): + with pytest.raises(OperationTimeoutError): + receiver.purge_messages(timeout=5) + + receiver.delete_messages.assert_called_once() + + +class TestDeleteMessagesAsync: + @pytest.mark.asyncio + async def test_returns_actual_count_and_uses_one_shot_dispatch(self): + receiver = _receiver(AsyncServiceBusReceiver) + cutoff = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc) + calls = [] + + async def dispatch(operation, message, callback, **kwargs): + calls.append((operation, message, callback, kwargs)) + return 7 + + receiver._mgmt_request_response = dispatch + + result = await receiver.delete_messages( + 10, before_enqueued_time=cutoff, timeout=12 + ) + + assert result.deleted_message_count == 7 + assert len(calls) == 1 + assert calls[0][0] == REQUEST_RESPONSE_BATCH_DELETE_MESSAGES_OPERATION + assert calls[0][2].func is mgmt_handlers.batch_delete_op + assert calls[0][2].keywords["max_message_count"] == 10 + assert calls[0][1][MGMT_REQUEST_MESSAGE_COUNT] == 10 + assert calls[0][1][MGMT_REQUEST_ENQUEUED_TIME_UTC] == int( + cutoff.timestamp() * 1000 + ) + assert 0 < calls[0][3]["timeout"] <= 12 + receiver._open_with_retry.assert_awaited_once_with(timeout=12) + receiver._mgmt_request_response_with_retry.assert_not_called() + + @pytest.mark.asyncio + async def test_setup_failure_does_not_dispatch(self): + receiver = _receiver(AsyncServiceBusReceiver) + receiver._open_with_retry.side_effect = RuntimeError("open failed") + receiver._mgmt_request_response = AsyncMock() + + with pytest.raises(RuntimeError, match="open failed"): + await receiver.delete_messages(1) + + receiver._mgmt_request_response.assert_not_awaited() + + @pytest.mark.asyncio + async def test_populates_session_id_after_setup_resolves_next_session(self): + receiver = _receiver( + AsyncServiceBusReceiver, session_id="" + ) + + async def resolve_session(**_): + receiver._session_id = "session-a" + + receiver._open_with_retry.side_effect = resolve_session + receiver._mgmt_request_response = AsyncMock(return_value=1) + + await receiver.delete_messages(1) + + message = receiver._mgmt_request_response.call_args.args[1] + assert message[MGMT_REQUEST_SESSION_ID] == "session-a" + + @pytest.mark.asyncio + async def test_open_readiness_respects_timeout(self): + receiver = _receiver(AsyncServiceBusReceiver) + receiver._running = False + receiver._connection = None + receiver._auto_lock_renewer = None + receiver._receive_mode = ServiceBusReceiveMode.PEEK_LOCK + receiver._handler._shutdown = False + receiver._handler.close_async = AsyncMock() + receiver._handler.open_async = AsyncMock() + receiver._handler.client_ready_async = AsyncMock(return_value=False) + receiver._amqp_transport.drain_and_release_messages_async = AsyncMock() + receiver._create_handler = MagicMock() + + with patch( + "azure.servicebus.aio._servicebus_receiver_async.create_authentication", + new=AsyncMock(return_value=None), + ), patch( + "azure.servicebus.aio._servicebus_receiver_async.time.monotonic", + side_effect=[10.0, 11.0], + ), patch( + "azure.servicebus.aio._servicebus_receiver_async.asyncio.sleep", + new=AsyncMock(), + ): + with pytest.raises(OperationTimeoutError): + await receiver._open(timeout=0.5) + + @pytest.mark.asyncio + async def test_dispatch_failure_is_not_retried(self): + receiver = _receiver(AsyncServiceBusReceiver) + receiver._mgmt_request_response = AsyncMock( + side_effect=RuntimeError("dispatch failed") + ) + + with pytest.raises(RuntimeError, match="dispatch failed"): + await receiver.delete_messages(1) + + receiver._open_with_retry.assert_awaited_once_with(timeout=None) + receiver._mgmt_request_response.assert_awaited_once() + receiver._mgmt_request_response_with_retry.assert_not_called() + + @pytest.mark.asyncio + async def test_dispatch_uses_remaining_timeout_and_stops_when_setup_exhausts_it( + self, + ): + receiver = _receiver(AsyncServiceBusReceiver) + receiver._mgmt_request_response = AsyncMock(return_value=1) + + with patch( + "azure.servicebus.aio._servicebus_receiver_async.time.monotonic", + side_effect=[10.0, 11.5], + ): + await receiver.delete_messages(1, timeout=2) + + assert receiver._mgmt_request_response.call_args.kwargs[ + "timeout" + ] == pytest.approx(0.5) + + receiver._mgmt_request_response.reset_mock() + with patch( + "azure.servicebus.aio._servicebus_receiver_async.time.monotonic", + side_effect=[20.0, 21.0], + ): + with pytest.raises(OperationTimeoutError, match="Operation timed out"): + await receiver.delete_messages(1, timeout=0.5) + + receiver._mgmt_request_response.assert_not_awaited() + + @pytest.mark.asyncio + async def test_keeps_one_cutoff_and_stops_only_on_zero(self): + receiver = _receiver(AsyncServiceBusReceiver) + counts = iter([500, 2, 0]) + calls = [] + + async def dispatch(operation, message, callback, **kwargs): + calls.append((message.copy(), kwargs)) + return next(counts) + + receiver._mgmt_request_response = dispatch + + cutoff = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc) + result = await receiver.purge_messages(before_enqueued_time=cutoff, timeout=18) + + assert result.deleted_message_count == 502 + assert len(calls) == 3 + assert [call[0][MGMT_REQUEST_MESSAGE_COUNT] for call in calls] == [ + 500, + 500, + 500, + ] + cutoffs = [call[0][MGMT_REQUEST_ENQUEUED_TIME_UTC] for call in calls] + assert cutoffs[0] == cutoffs[1] == cutoffs[2] + assert cutoffs[0] == int(cutoff.timestamp() * 1000) + assert all(0 < call[1]["timeout"] <= 18 for call in calls) + + @pytest.mark.asyncio + async def test_purge_supports_premium_batch_size(self): + receiver = _receiver(AsyncServiceBusReceiver) + receiver._mgmt_request_response = AsyncMock(side_effect=[4000, 2, 0]) + + result = await receiver.purge_messages(max_message_count_per_batch=4000) + + assert result.deleted_message_count == 4002 + calls = receiver._mgmt_request_response.await_args_list + assert [call.args[1][MGMT_REQUEST_MESSAGE_COUNT] for call in calls] == [ + 4000, + 4000, + 4000, + ] + cutoffs = [call.args[1][MGMT_REQUEST_ENQUEUED_TIME_UTC] for call in calls] + assert cutoffs[0] == cutoffs[1] == cutoffs[2] + + @pytest.mark.asyncio + async def test_purge_allows_service_to_enforce_batch_size(self): + receiver = _receiver(AsyncServiceBusReceiver) + receiver._mgmt_request_response = AsyncMock(return_value=0) + + await receiver.purge_messages(max_message_count_per_batch=4001) + + assert receiver._mgmt_request_response.call_args.args[1][MGMT_REQUEST_MESSAGE_COUNT] == 4001 + + @pytest.mark.asyncio + async def test_purge_uses_one_operation_deadline(self): + receiver = _receiver(AsyncServiceBusReceiver) + receiver.delete_messages = AsyncMock( + side_effect=[DeleteMessagesResult(1), DeleteMessagesResult(0)] + ) + + with patch( + "azure.servicebus.aio._servicebus_receiver_async.time.monotonic", + side_effect=[10.0, 11.0, 12.0], + ): + result = await receiver.purge_messages(timeout=5) + + assert result.deleted_message_count == 1 + assert [call.kwargs["timeout"] for call in receiver.delete_messages.await_args_list] == [4.0, 3.0] + + @pytest.mark.asyncio + async def test_purge_deadline_stops_before_another_dispatch(self): + receiver = _receiver(AsyncServiceBusReceiver) + receiver.delete_messages = AsyncMock(return_value=DeleteMessagesResult(1)) + + with patch( + "azure.servicebus.aio._servicebus_receiver_async.time.monotonic", + side_effect=[20.0, 21.0, 25.1], + ): + with pytest.raises(OperationTimeoutError): + await receiver.purge_messages(timeout=5) + + receiver.delete_messages.assert_awaited_once() From 25769342323daabce14a2e8712521595ea85ca52 Mon Sep 17 00:00:00 2001 From: Eldert Grootenboer Date: Wed, 2 Sep 2026 11:15:25 -0700 Subject: [PATCH 2/8] fix: address batch delete review feedback --- .../azure/servicebus/_base_handler.py | 26 +++++- .../servicebus/_pyamqp/aio/_client_async.py | 43 ++++++---- .../azure/servicebus/_pyamqp/client.py | 42 ++++++---- .../azure/servicebus/_servicebus_receiver.py | 6 ++ .../azure/servicebus/_transport/_base.py | 5 ++ .../_transport/_pyamqp_transport.py | 5 ++ .../servicebus/_transport/_uamqp_transport.py | 5 ++ .../servicebus/aio/_base_handler_async.py | 26 +++++- .../aio/_servicebus_receiver_async.py | 6 ++ .../servicebus/aio/_transport/_base_async.py | 5 ++ .../aio/_transport/_pyamqp_transport_async.py | 5 ++ .../aio/_transport/_uamqp_transport_async.py | 5 ++ .../sample_code_servicebus_async.py | 1 - .../sync_samples/sample_code_servicebus.py | 1 - .../tests/unittests/test_batch_delete.py | 81 ++++++++++++++++++- 15 files changed, 225 insertions(+), 37 deletions(-) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py index cbefeb1c7ac9..63d557acad80 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py @@ -38,6 +38,7 @@ MGMT_REQUEST_OP_TYPE_ENTITY_MGMT, ASSOCIATEDLINKPROPERTYNAME, REQUEST_RESPONSE_TIMEOUT, + NEXT_AVAILABLE_SESSION, ) if TYPE_CHECKING: @@ -408,7 +409,9 @@ def _do_retryable_operation( # pylint: disable=inconsistent-return-statements self._container_id, last_exception, ) - if isinstance(last_exception, OperationTimeoutError): + if isinstance(last_exception, OperationTimeoutError) and ( + getattr(self, "_session_id", None) == NEXT_AVAILABLE_SESSION + ): description = ( "If trying to receive from NEXT_AVAILABLE_SESSION, " "use max_wait_time on the ServiceBusReceiver to control the" @@ -454,7 +457,9 @@ def _backoff( entity_name, last_exception, ) - if isinstance(last_exception, OperationTimeoutError): + if isinstance(last_exception, OperationTimeoutError) and ( + getattr(self, "_session_id", None) == NEXT_AVAILABLE_SESSION + ): description = ( "If trying to receive from NEXT_AVAILABLE_SESSION, " "use max_wait_time on the ServiceBusReceiver to control the" @@ -571,6 +576,23 @@ def open_with_timeout(timeout: Optional[float] = None): operation_requires_timeout=timeout is not None, ) + def _open_mgmt_link_with_retry(self, timeout: Optional[float] = None): + def open_mgmt_link(timeout: Optional[float] = None): + if timeout is not None and timeout <= 0: + raise OperationTimeoutError() + self._open() + return self._amqp_transport.mgmt_client_setup( + self._handler, + node=self._mgmt_target.encode(self._config.encoding), + timeout=timeout, + ) + + return self._do_retryable_operation( + open_mgmt_link, + timeout=timeout, + operation_requires_timeout=timeout is not None, + ) + def _close_handler(self): if self._handler: self._handler.close() diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/aio/_client_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/aio/_client_async.py index 087a0c48b243..7b14d9b354b1 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/aio/_client_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/aio/_client_async.py @@ -368,9 +368,17 @@ async def mgmt_request_async( :rtype: ~pyamqp.message.Message """ - # The method also takes "status_code_field" and "status_description_field" - # keyword arguments as alternate names for the status code and description - # in the response body. Those two keyword arguments are used in Azure services only. + mgmt_link = await self.open_mgmt_link_async(node=node, timeout=timeout, **kwargs) + + operation_type = operation_type or b"empty" + status, description, response = await mgmt_link.execute( + message, operation=operation, operation_type=operation_type, timeout=timeout + ) + return status, description, response + + async def open_mgmt_link_async(self, node: str = "$management", timeout: float = 0, **kwargs): + """Open and wait for a management link without dispatching a request.""" + start_time = time.monotonic() async with self._mgmt_link_lock_async: try: mgmt_link = self._mgmt_links[node] @@ -378,18 +386,23 @@ async def mgmt_request_async( mgmt_link = ManagementOperation(self._session, endpoint=node, **kwargs) self._mgmt_links[node] = mgmt_link await mgmt_link.open() - - while not await self.client_ready_async(): - await asyncio.sleep(0.05) - - while not await mgmt_link.ready(): - await self._connection.listen(wait=False) - - operation_type = operation_type or b"empty" - status, description, response = await mgmt_link.execute( - message, operation=operation, operation_type=operation_type, timeout=timeout - ) - return status, description, response + try: + while not await self.client_ready_async(): + if timeout and time.monotonic() - start_time >= timeout: + raise TimeoutError("Management link setup timed out.") + await asyncio.sleep(0.05) + + while not await mgmt_link.ready(): + if timeout and time.monotonic() - start_time >= timeout: + raise TimeoutError("Management link setup timed out.") + await self._connection.listen(wait=False) + return mgmt_link + except Exception: + async with self._mgmt_link_lock_async: + if self._mgmt_links.get(node) is mgmt_link: + self._mgmt_links.pop(node, None) + await mgmt_link.close() + raise class SendClientAsync(SendClientSync, AMQPClientAsync): diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/client.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/client.py index 657c9bd8a879..70651706e3ba 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/client.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/client.py @@ -455,9 +455,17 @@ def mgmt_request( :rtype: ~pyamqp.message.Message """ - # The method also takes "status_code_field" and "status_description_field" - # keyword arguments as alternate names for the status code and description - # in the response body. Those two keyword arguments are used in Azure services only. + mgmt_link = self.open_mgmt_link(node=node, timeout=timeout, **kwargs) + + operation_type = operation_type or b"empty" + status, description, response = mgmt_link.execute( + message, operation=operation, operation_type=operation_type, timeout=timeout + ) + return status, description, response + + def open_mgmt_link(self, node: str = "$management", timeout: float = 0, **kwargs): + """Open and wait for a management link without dispatching a request.""" + start_time = time.monotonic() with self._mgmt_link_lock: try: mgmt_link = self._mgmt_links[node] @@ -465,17 +473,23 @@ def mgmt_request( mgmt_link = ManagementOperation(self._session, endpoint=node, **kwargs) self._mgmt_links[node] = mgmt_link mgmt_link.open() - - while not self.client_ready(): - time.sleep(0.05) - - while not mgmt_link.ready(): - self._connection.listen(wait=False) - operation_type = operation_type or b"empty" - status, description, response = mgmt_link.execute( - message, operation=operation, operation_type=operation_type, timeout=timeout - ) - return status, description, response + try: + while not self.client_ready(): + if timeout and time.monotonic() - start_time >= timeout: + raise TimeoutError("Management link setup timed out.") + time.sleep(0.05) + + while not mgmt_link.ready(): + if timeout and time.monotonic() - start_time >= timeout: + raise TimeoutError("Management link setup timed out.") + self._connection.listen(wait=False) + return mgmt_link + except Exception: + with self._mgmt_link_lock: + if self._mgmt_links.get(node) is mgmt_link: + self._mgmt_links.pop(node, None) + mgmt_link.close() + raise class SendClient(AMQPClient): diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py index a7b5422e9ef6..67f93cdada71 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py @@ -891,6 +891,12 @@ def delete_messages( remaining_timeout = ( None if timeout is None else timeout - (time.monotonic() - start_time) ) + if remaining_timeout is not None and remaining_timeout <= 0: + raise OperationTimeoutError() + self._open_mgmt_link_with_retry(timeout=remaining_timeout) + remaining_timeout = ( + None if timeout is None else timeout - (time.monotonic() - start_time) + ) if remaining_timeout is not None and remaining_timeout <= 0: raise OperationTimeoutError() self._populate_message_properties(message) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_base.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_base.py index 42b5da84bffc..ebd575e59b39 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_base.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_base.py @@ -377,6 +377,11 @@ def create_mgmt_msg(message, application_properties, config, reply_to, **kwargs) :rtype: uamqp.Message or pyamqp.Message """ + @staticmethod + @abstractmethod + def mgmt_client_setup(mgmt_client, *, node, timeout): + """Open the management link without dispatching a request.""" + @staticmethod @abstractmethod def mgmt_client_request(mgmt_client, mgmt_msg, *, operation, operation_type, node, timeout, callback): diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py index 480f8619ab0e..a38d70903247 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py @@ -1082,6 +1082,11 @@ def mgmt_client_request( ) return callback(status, response, description, amqp_transport=PyamqpTransport) + @staticmethod + def mgmt_client_setup(mgmt_client: "AMQPClient", *, node: str, timeout: int) -> None: + """Open the pyamqp management link without dispatching a request.""" + mgmt_client.open_mgmt_link(node=node, timeout=timeout or 0) + @staticmethod def _handle_amqp_exception_with_condition( logger: "Logger", diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_uamqp_transport.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_uamqp_transport.py index 22fa13712dc2..061c095efa86 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_uamqp_transport.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_uamqp_transport.py @@ -1075,6 +1075,11 @@ def mgmt_client_request( callback=functools.partial(callback, amqp_transport=UamqpTransport), ) + @staticmethod + def mgmt_client_setup(mgmt_client, *, node, timeout) -> None: + """uAMQP does not expose management-link setup separately from request dispatch.""" + del mgmt_client, node, timeout + @staticmethod def _handle_amqp_exception_with_condition( logger: "Logger", diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py index 3a3f10d4111e..89a41403b8c3 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py @@ -26,6 +26,7 @@ CONTAINER_PREFIX, MANAGEMENT_PATH_SUFFIX, REQUEST_RESPONSE_TIMEOUT, + NEXT_AVAILABLE_SESSION, ) from ..exceptions import ( ServiceBusConnectionError, @@ -256,7 +257,9 @@ async def _do_retryable_operation(self, operation: Callable, timeout: Optional[f self._container_id, last_exception, ) - if isinstance(last_exception, OperationTimeoutError): + if isinstance(last_exception, OperationTimeoutError) and ( + getattr(self, "_session_id", None) == NEXT_AVAILABLE_SESSION + ): description = ( "If trying to receive from NEXT_AVAILABLE_SESSION, " "use max_wait_time on the ServiceBusReceiver to control the" @@ -296,7 +299,9 @@ async def _backoff(self, retried_times, last_exception, abs_timeout_time=None, e entity_name, last_exception, ) - if isinstance(last_exception, OperationTimeoutError): + if isinstance(last_exception, OperationTimeoutError) and ( + getattr(self, "_session_id", None) == NEXT_AVAILABLE_SESSION + ): description = ( "If trying to receive from NEXT_AVAILABLE_SESSION, " "use max_wait_time on the ServiceBusReceiver to control the" @@ -412,6 +417,23 @@ async def open_with_timeout(timeout: Optional[float] = None): operation_requires_timeout=timeout is not None, ) + async def _open_mgmt_link_with_retry(self, timeout: Optional[float] = None): + async def open_mgmt_link(timeout: Optional[float] = None): + if timeout is not None and timeout <= 0: + raise OperationTimeoutError() + await self._open() + return await self._amqp_transport.mgmt_client_setup_async( + self._handler, + node=self._mgmt_target.encode(self._config.encoding), + timeout=timeout, + ) + + return await self._do_retryable_operation( + open_mgmt_link, + timeout=timeout, + operation_requires_timeout=timeout is not None, + ) + async def _close_handler(self): if self._handler: await self._handler.close_async() diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py index 3149b96a2cec..c6f0104e3436 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py @@ -886,6 +886,12 @@ async def delete_messages( remaining_timeout = ( None if timeout is None else timeout - (time.monotonic() - start_time) ) + if remaining_timeout is not None and remaining_timeout <= 0: + raise OperationTimeoutError() + await self._open_mgmt_link_with_retry(timeout=remaining_timeout) + remaining_timeout = ( + None if timeout is None else timeout - (time.monotonic() - start_time) + ) if remaining_timeout is not None and remaining_timeout <= 0: raise OperationTimeoutError() self._populate_message_properties(message) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_base_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_base_async.py index 79837d43fe10..ef0356d0427c 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_base_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_base_async.py @@ -303,6 +303,11 @@ async def create_token_auth_async(auth_uri, get_token, token_type, config, **kwa :rtype: ~pyamqp.aio._authentication_async.JWTTokenAuth or ~uamqp.authentication.JWTTokenAuth """ + @staticmethod + @abstractmethod + async def mgmt_client_setup_async(mgmt_client, *, node, timeout): + """Open the management link without dispatching a request.""" + @staticmethod @abstractmethod async def mgmt_client_request_async(mgmt_client, mgmt_msg, *, operation, operation_type, node, timeout, callback): diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_pyamqp_transport_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_pyamqp_transport_async.py index 2aea3a9448ef..ad31910b812d 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_pyamqp_transport_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_pyamqp_transport_async.py @@ -528,3 +528,8 @@ async def mgmt_client_request_async( timeout=timeout, # TODO: check if this should be seconds * 1000 if timeout else None, ) return callback(status, response, description, amqp_transport=PyamqpTransportAsync) + + @staticmethod + async def mgmt_client_setup_async(mgmt_client: "AMQPClientAsync", *, node: str, timeout: int) -> None: + """Open the pyamqp management link without dispatching a request.""" + await mgmt_client.open_mgmt_link_async(node=node, timeout=timeout or 0) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_uamqp_transport_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_uamqp_transport_async.py index d5fc57b06900..d567fd64dd1d 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_uamqp_transport_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_uamqp_transport_async.py @@ -369,5 +369,10 @@ async def mgmt_client_request_async( callback=functools.partial(callback, amqp_transport=UamqpTransportAsync), ) + @staticmethod + async def mgmt_client_setup_async(mgmt_client, *, node, timeout) -> None: + """uAMQP does not expose management-link setup separately from request dispatch.""" + del mgmt_client, node, timeout + except ImportError: pass diff --git a/sdk/servicebus/azure-servicebus/samples/async_samples/sample_code_servicebus_async.py b/sdk/servicebus/azure-servicebus/samples/async_samples/sample_code_servicebus_async.py index a47e9ab4bbfb..00cf251b5f4f 100644 --- a/sdk/servicebus/azure-servicebus/samples/async_samples/sample_code_servicebus_async.py +++ b/sdk/servicebus/azure-servicebus/samples/async_samples/sample_code_servicebus_async.py @@ -479,5 +479,4 @@ async def example_schedule_ops_async(): asyncio.run(example_receive_deadletter_async()) asyncio.run(example_session_ops_async()) asyncio.run(example_delete_and_purge_messages_async()) - asyncio.run(example_purge_messages_advanced_async()) asyncio.run(example_purge_messages_from_session_async()) diff --git a/sdk/servicebus/azure-servicebus/samples/sync_samples/sample_code_servicebus.py b/sdk/servicebus/azure-servicebus/samples/sync_samples/sample_code_servicebus.py index 9e7a67d82ab3..dc2118411d88 100644 --- a/sdk/servicebus/azure-servicebus/samples/sync_samples/sample_code_servicebus.py +++ b/sdk/servicebus/azure-servicebus/samples/sync_samples/sample_code_servicebus.py @@ -503,5 +503,4 @@ def example_schedule_ops_sync(): example_receive_deadletter_sync() example_session_ops_sync() example_delete_and_purge_messages_sync() -example_purge_messages_advanced_sync() example_purge_messages_from_session_sync() diff --git a/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py b/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py index 888fb0596b4b..eac526bcf0d5 100644 --- a/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py +++ b/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py @@ -17,6 +17,7 @@ MGMT_REQUEST_SESSION_ID, MGMT_RESPONSE_MESSAGE_ERROR_CONDITION, REQUEST_RESPONSE_BATCH_DELETE_MESSAGES_OPERATION, + NEXT_AVAILABLE_SESSION, ServiceBusReceiveMode, ) from azure.servicebus._common import mgmt_handlers @@ -106,6 +107,9 @@ def _receiver(receiver_type, session_id=None): receiver._open_with_retry = ( AsyncMock() if receiver_type is AsyncServiceBusReceiver else MagicMock() ) + receiver._open_mgmt_link_with_retry = ( + AsyncMock() if receiver_type is AsyncServiceBusReceiver else MagicMock() + ) receiver._mgmt_request_response_with_retry = MagicMock( side_effect=AssertionError("destructive requests must not be retried") ) @@ -113,6 +117,26 @@ def _receiver(receiver_type, session_id=None): class TestDeleteMessages: + @pytest.mark.parametrize( + "session_id,expects_session_advice", + [(None, False), (NEXT_AVAILABLE_SESSION, True)], + ) + def test_setup_timeout_advice_only_applies_to_next_available_session( + self, session_id, expects_session_advice + ): + receiver = _receiver(ServiceBusReceiver, session_id=session_id) + receiver._config = MagicMock(retry_total=0) + receiver._container_id = "receiver" + receiver._handle_exception = lambda error: error + + def time_out(): + raise OperationTimeoutError() + + with pytest.raises(OperationTimeoutError) as exc_info: + receiver._do_retryable_operation(time_out) + + assert ("NEXT_AVAILABLE_SESSION" in str(exc_info.value)) is expects_session_advice + def test_returns_actual_count_and_uses_one_shot_dispatch(self): receiver = _receiver(ServiceBusReceiver) cutoff = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc) @@ -137,6 +161,7 @@ def dispatch(operation, message, callback, **kwargs): ) assert 0 < calls[0][3]["timeout"] <= 12 receiver._open_with_retry.assert_called_once_with(timeout=12) + receiver._open_mgmt_link_with_retry.assert_called_once() receiver._mgmt_request_response_with_retry.assert_not_called() def test_setup_failure_does_not_dispatch(self): @@ -149,6 +174,16 @@ def test_setup_failure_does_not_dispatch(self): receiver._mgmt_request_response.assert_not_called() + def test_management_setup_failure_does_not_dispatch(self): + receiver = _receiver(ServiceBusReceiver) + receiver._open_mgmt_link_with_retry.side_effect = RuntimeError("management open failed") + receiver._mgmt_request_response = MagicMock() + + with pytest.raises(RuntimeError, match="management open failed"): + receiver.delete_messages(1) + + receiver._mgmt_request_response.assert_not_called() + def test_dispatch_failure_is_not_retried(self): receiver = _receiver(ServiceBusReceiver) receiver._mgmt_request_response = MagicMock( @@ -168,13 +203,16 @@ def test_dispatch_uses_remaining_timeout_and_stops_when_setup_exhausts_it(self): with patch( "azure.servicebus._servicebus_receiver.time.monotonic", - side_effect=[10.0, 11.5], + side_effect=[10.0, 11.0, 11.5], ): receiver.delete_messages(1, timeout=2) assert receiver._mgmt_request_response.call_args.kwargs[ "timeout" ] == pytest.approx(0.5) + assert receiver._open_mgmt_link_with_retry.call_args.kwargs[ + "timeout" + ] == pytest.approx(1.0) receiver._mgmt_request_response.reset_mock() with patch( @@ -366,6 +404,30 @@ def test_operation_deadline_stops_before_another_dispatch(self): class TestDeleteMessagesAsync: + @pytest.mark.asyncio + @pytest.mark.parametrize( + "session_id,expects_session_advice", + [(None, False), (NEXT_AVAILABLE_SESSION, True)], + ) + async def test_setup_timeout_advice_only_applies_to_next_available_session( + self, session_id, expects_session_advice + ): + receiver = _receiver(AsyncServiceBusReceiver, session_id=session_id) + receiver._config = MagicMock(retry_total=0) + receiver._container_id = "receiver" + + async def handle_exception(error): + return error + + async def time_out(): + raise OperationTimeoutError() + + receiver._handle_exception = handle_exception + with pytest.raises(OperationTimeoutError) as exc_info: + await receiver._do_retryable_operation(time_out) + + assert ("NEXT_AVAILABLE_SESSION" in str(exc_info.value)) is expects_session_advice + @pytest.mark.asyncio async def test_returns_actual_count_and_uses_one_shot_dispatch(self): receiver = _receiver(AsyncServiceBusReceiver) @@ -393,6 +455,7 @@ async def dispatch(operation, message, callback, **kwargs): ) assert 0 < calls[0][3]["timeout"] <= 12 receiver._open_with_retry.assert_awaited_once_with(timeout=12) + receiver._open_mgmt_link_with_retry.assert_awaited_once() receiver._mgmt_request_response_with_retry.assert_not_called() @pytest.mark.asyncio @@ -406,6 +469,17 @@ async def test_setup_failure_does_not_dispatch(self): receiver._mgmt_request_response.assert_not_awaited() + @pytest.mark.asyncio + async def test_management_setup_failure_does_not_dispatch(self): + receiver = _receiver(AsyncServiceBusReceiver) + receiver._open_mgmt_link_with_retry.side_effect = RuntimeError("management open failed") + receiver._mgmt_request_response = AsyncMock() + + with pytest.raises(RuntimeError, match="management open failed"): + await receiver.delete_messages(1) + + receiver._mgmt_request_response.assert_not_awaited() + @pytest.mark.asyncio async def test_populates_session_id_after_setup_resolves_next_session(self): receiver = _receiver( @@ -473,13 +547,16 @@ async def test_dispatch_uses_remaining_timeout_and_stops_when_setup_exhausts_it( with patch( "azure.servicebus.aio._servicebus_receiver_async.time.monotonic", - side_effect=[10.0, 11.5], + side_effect=[10.0, 11.0, 11.5], ): await receiver.delete_messages(1, timeout=2) assert receiver._mgmt_request_response.call_args.kwargs[ "timeout" ] == pytest.approx(0.5) + assert receiver._open_mgmt_link_with_retry.call_args.kwargs[ + "timeout" + ] == pytest.approx(1.0) receiver._mgmt_request_response.reset_mock() with patch( From 9590c7a36d7fd5c4d5070251119d4e3b584c9eba Mon Sep 17 00:00:00 2001 From: Eldert Grootenboer Date: Wed, 2 Sep 2026 13:58:37 -0700 Subject: [PATCH 3/8] fix: address batch delete review feedback - Align management-link transport documentation - Resolve batch delete typing across sync and async clients --- .../azure/servicebus/_pyamqp/aio/_client_async.py | 8 +++++++- .../azure-servicebus/azure/servicebus/_pyamqp/client.py | 8 +++++++- .../azure/servicebus/_servicebus_receiver.py | 2 +- .../azure/servicebus/_transport/_base.py | 7 ++++++- .../azure/servicebus/_transport/_pyamqp_transport.py | 7 ++++++- .../azure/servicebus/_transport/_uamqp_transport.py | 7 ++++++- .../azure/servicebus/aio/_servicebus_receiver_async.py | 2 +- .../azure/servicebus/aio/_transport/_base_async.py | 9 ++++++++- .../servicebus/aio/_transport/_pyamqp_transport_async.py | 7 ++++++- .../servicebus/aio/_transport/_uamqp_transport_async.py | 7 ++++++- 10 files changed, 54 insertions(+), 10 deletions(-) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/aio/_client_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/aio/_client_async.py index 7b14d9b354b1..f1a86d15a6ca 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/aio/_client_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/aio/_client_async.py @@ -377,7 +377,13 @@ async def mgmt_request_async( return status, description, response async def open_mgmt_link_async(self, node: str = "$management", timeout: float = 0, **kwargs): - """Open and wait for a management link without dispatching a request.""" + """Open and wait for a management link without dispatching a request. + + :param str node: Management target. + :param float timeout: Timeout in seconds. + :returns: The opened management link. + :rtype: ~pyamqp.aio.management_link_async.ManagementOperation + """ start_time = time.monotonic() async with self._mgmt_link_lock_async: try: diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/client.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/client.py index 70651706e3ba..b4f5d0b52cae 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/client.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/client.py @@ -464,7 +464,13 @@ def mgmt_request( return status, description, response def open_mgmt_link(self, node: str = "$management", timeout: float = 0, **kwargs): - """Open and wait for a management link without dispatching a request.""" + """Open and wait for a management link without dispatching a request. + + :param str node: Management target. + :param float timeout: Timeout in seconds. + :returns: The opened management link. + :rtype: ~pyamqp.management_link.ManagementOperation + """ start_time = time.monotonic() with self._mgmt_link_lock: try: diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py index 67f93cdada71..a1896a053c75 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py @@ -908,7 +908,7 @@ def delete_messages( ), timeout=remaining_timeout, ) - return DeleteMessagesResult(deleted_count) + return DeleteMessagesResult(cast(int, deleted_count)) def purge_messages( self, diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_base.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_base.py index ebd575e59b39..2caeef719c61 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_base.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_base.py @@ -380,7 +380,12 @@ def create_mgmt_msg(message, application_properties, config, reply_to, **kwargs) @staticmethod @abstractmethod def mgmt_client_setup(mgmt_client, *, node, timeout): - """Open the management link without dispatching a request.""" + """Open the management link without dispatching a request. + + :param AMQPClient mgmt_client: Client used to open the management link. + :keyword bytes node: Management target. + :keyword int timeout: Timeout in seconds. + """ @staticmethod @abstractmethod diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py index a38d70903247..9c5632ea8de8 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py @@ -1084,7 +1084,12 @@ def mgmt_client_request( @staticmethod def mgmt_client_setup(mgmt_client: "AMQPClient", *, node: str, timeout: int) -> None: - """Open the pyamqp management link without dispatching a request.""" + """Open the pyamqp management link without dispatching a request. + + :param ~pyamqp.AMQPClient mgmt_client: Client used for management requests. + :keyword str node: Management target. + :keyword int timeout: Timeout in seconds. + """ mgmt_client.open_mgmt_link(node=node, timeout=timeout or 0) @staticmethod diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_uamqp_transport.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_uamqp_transport.py index 061c095efa86..d6043e5dc6cf 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_uamqp_transport.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_uamqp_transport.py @@ -1077,7 +1077,12 @@ def mgmt_client_request( @staticmethod def mgmt_client_setup(mgmt_client, *, node, timeout) -> None: - """uAMQP does not expose management-link setup separately from request dispatch.""" + """uAMQP does not expose management-link setup separately from request dispatch. + + :param ~uamqp.AMQPClient mgmt_client: Client used for management requests. + :keyword bytes node: Management target. + :keyword int timeout: Timeout in seconds. + """ del mgmt_client, node, timeout @staticmethod diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py index c6f0104e3436..5e88224c2457 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py @@ -903,7 +903,7 @@ async def delete_messages( ), timeout=remaining_timeout, ) - return DeleteMessagesResult(deleted_count) + return DeleteMessagesResult(cast(int, deleted_count)) async def purge_messages( self, diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_base_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_base_async.py index ef0356d0427c..6881f640508a 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_base_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_base_async.py @@ -39,6 +39,8 @@ class AmqpTransportAsync(ABC): # pylint: disable=too-many-public-methods AMQP_LONG_VALUE: Callable AMQP_ARRAY_VALUE: Callable AMQP_UINT_VALUE: Callable + AMQP_INT_VALUE: Callable + AMQP_TIMESTAMP_VALUE: Callable @staticmethod @abstractmethod @@ -306,7 +308,12 @@ async def create_token_auth_async(auth_uri, get_token, token_type, config, **kwa @staticmethod @abstractmethod async def mgmt_client_setup_async(mgmt_client, *, node, timeout): - """Open the management link without dispatching a request.""" + """Open the management link without dispatching a request. + + :param AMQPClient mgmt_client: Client used to open the management link. + :keyword bytes node: Management target. + :keyword int timeout: Timeout in seconds. + """ @staticmethod @abstractmethod diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_pyamqp_transport_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_pyamqp_transport_async.py index ad31910b812d..81da9e2d029a 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_pyamqp_transport_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_pyamqp_transport_async.py @@ -531,5 +531,10 @@ async def mgmt_client_request_async( @staticmethod async def mgmt_client_setup_async(mgmt_client: "AMQPClientAsync", *, node: str, timeout: int) -> None: - """Open the pyamqp management link without dispatching a request.""" + """Open the pyamqp management link without dispatching a request. + + :param ~pyamqp.aio.AMQPClientAsync mgmt_client: Client used for management requests. + :keyword str node: Management target. + :keyword int timeout: Timeout in seconds. + """ await mgmt_client.open_mgmt_link_async(node=node, timeout=timeout or 0) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_uamqp_transport_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_uamqp_transport_async.py index d567fd64dd1d..26197a5dd51d 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_uamqp_transport_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_uamqp_transport_async.py @@ -371,7 +371,12 @@ async def mgmt_client_request_async( @staticmethod async def mgmt_client_setup_async(mgmt_client, *, node, timeout) -> None: - """uAMQP does not expose management-link setup separately from request dispatch.""" + """uAMQP does not expose management-link setup separately from request dispatch. + + :param ~uamqp.AMQPClientAsync mgmt_client: Client used for management requests. + :keyword bytes node: Management target. + :keyword int timeout: Timeout in seconds. + """ del mgmt_client, node, timeout except ImportError: From 42a8416b8840dc0845985b471bb7d43d683926aa Mon Sep 17 00:00:00 2001 From: Eldert Grootenboer Date: Thu, 3 Sep 2026 11:08:22 -0700 Subject: [PATCH 4/8] fix: enforce batch delete setup deadline --- .../azure/servicebus/_base_handler.py | 38 ++++++- .../azure/servicebus/_servicebus_receiver.py | 10 +- .../servicebus/aio/_base_handler_async.py | 46 +++++++- .../aio/_servicebus_receiver_async.py | 10 +- .../tests/unittests/test_batch_delete.py | 101 +++++++++++++++--- 5 files changed, 182 insertions(+), 23 deletions(-) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py index 63d557acad80..5425357e3089 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py @@ -382,6 +382,9 @@ def _do_retryable_operation( # pylint: disable=inconsistent-return-statements ) -> Any: require_last_exception = kwargs.pop("require_last_exception", False) operation_requires_timeout = kwargs.pop("operation_requires_timeout", False) + suppress_next_session_timeout_message = kwargs.pop( + "suppress_next_session_timeout_message", False + ) retried_times = 0 max_retries = self._config.retry_total @@ -411,6 +414,7 @@ def _do_retryable_operation( # pylint: disable=inconsistent-return-statements ) if isinstance(last_exception, OperationTimeoutError) and ( getattr(self, "_session_id", None) == NEXT_AVAILABLE_SESSION + and not suppress_next_session_timeout_message ): description = ( "If trying to receive from NEXT_AVAILABLE_SESSION, " @@ -426,6 +430,9 @@ def _do_retryable_operation( # pylint: disable=inconsistent-return-statements retried_times=retried_times, last_exception=last_exception, abs_timeout_time=abs_timeout_time, + suppress_next_session_timeout_message=( + suppress_next_session_timeout_message + ), ) def _backoff( @@ -434,6 +441,7 @@ def _backoff( last_exception: Exception, abs_timeout_time: Optional[float] = None, entity_name: Optional[str] = None, + suppress_next_session_timeout_message: bool = False, ) -> None: entity_name = entity_name or self._container_id backoff = _get_backoff_time( @@ -459,6 +467,7 @@ def _backoff( ) if isinstance(last_exception, OperationTimeoutError) and ( getattr(self, "_session_id", None) == NEXT_AVAILABLE_SESSION + and not suppress_next_session_timeout_message ): description = ( "If trying to receive from NEXT_AVAILABLE_SESSION, " @@ -562,7 +571,12 @@ def _open_with_timeout(self, timeout: float): del timeout return self._open() - def _open_with_retry(self, timeout: Optional[float] = None): + def _open_with_retry( + self, + timeout: Optional[float] = None, + *, + suppress_next_session_timeout_message: bool = False, + ): def open_with_timeout(timeout: Optional[float] = None): if timeout is not None and timeout <= 0: raise OperationTimeoutError() @@ -574,13 +588,28 @@ def open_with_timeout(timeout: Optional[float] = None): open_with_timeout, timeout=timeout, operation_requires_timeout=timeout is not None, + suppress_next_session_timeout_message=( + suppress_next_session_timeout_message + ), ) - def _open_mgmt_link_with_retry(self, timeout: Optional[float] = None): + def _open_mgmt_link_with_retry( + self, + timeout: Optional[float] = None, + *, + suppress_next_session_timeout_message: bool = False, + ): def open_mgmt_link(timeout: Optional[float] = None): if timeout is not None and timeout <= 0: raise OperationTimeoutError() - self._open() + start_time = time.monotonic() + if timeout is None: + self._open() + else: + self._open_with_timeout(timeout) + timeout -= time.monotonic() - start_time + if timeout <= 0: + raise OperationTimeoutError() return self._amqp_transport.mgmt_client_setup( self._handler, node=self._mgmt_target.encode(self._config.encoding), @@ -591,6 +620,9 @@ def open_mgmt_link(timeout: Optional[float] = None): open_mgmt_link, timeout=timeout, operation_requires_timeout=timeout is not None, + suppress_next_session_timeout_message=( + suppress_next_session_timeout_message + ), ) def _close_handler(self): diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py index a1896a053c75..8005c4680388 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py @@ -887,13 +887,19 @@ def delete_messages( ), } start_time = time.monotonic() - self._open_with_retry(timeout=timeout) + self._open_with_retry( + timeout=timeout, + suppress_next_session_timeout_message=True, + ) remaining_timeout = ( None if timeout is None else timeout - (time.monotonic() - start_time) ) if remaining_timeout is not None and remaining_timeout <= 0: raise OperationTimeoutError() - self._open_mgmt_link_with_retry(timeout=remaining_timeout) + self._open_mgmt_link_with_retry( + timeout=remaining_timeout, + suppress_next_session_timeout_message=True, + ) remaining_timeout = ( None if timeout is None else timeout - (time.monotonic() - start_time) ) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py index 89a41403b8c3..e954ecf09dd5 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py @@ -230,6 +230,9 @@ def _check_live(self): async def _do_retryable_operation(self, operation: Callable, timeout: Optional[float] = None, **kwargs: Any) -> Any: require_last_exception = kwargs.pop("require_last_exception", False) operation_requires_timeout = kwargs.pop("operation_requires_timeout", False) + suppress_next_session_timeout_message = kwargs.pop( + "suppress_next_session_timeout_message", False + ) retried_times = 0 max_retries = self._config.retry_total @@ -259,6 +262,7 @@ async def _do_retryable_operation(self, operation: Callable, timeout: Optional[f ) if isinstance(last_exception, OperationTimeoutError) and ( getattr(self, "_session_id", None) == NEXT_AVAILABLE_SESSION + and not suppress_next_session_timeout_message ): description = ( "If trying to receive from NEXT_AVAILABLE_SESSION, " @@ -274,9 +278,19 @@ async def _do_retryable_operation(self, operation: Callable, timeout: Optional[f retried_times=retried_times, last_exception=last_exception, abs_timeout_time=abs_timeout_time, + suppress_next_session_timeout_message=( + suppress_next_session_timeout_message + ), ) - async def _backoff(self, retried_times, last_exception, abs_timeout_time=None, entity_name=None): + async def _backoff( + self, + retried_times, + last_exception, + abs_timeout_time=None, + entity_name=None, + suppress_next_session_timeout_message=False, + ): entity_name = entity_name or self._container_id backoff = _get_backoff_time( self._config.retry_mode, @@ -301,6 +315,7 @@ async def _backoff(self, retried_times, last_exception, abs_timeout_time=None, e ) if isinstance(last_exception, OperationTimeoutError) and ( getattr(self, "_session_id", None) == NEXT_AVAILABLE_SESSION + and not suppress_next_session_timeout_message ): description = ( "If trying to receive from NEXT_AVAILABLE_SESSION, " @@ -403,7 +418,12 @@ async def _open_with_timeout(self, timeout: float): del timeout return await self._open() - async def _open_with_retry(self, timeout: Optional[float] = None): + async def _open_with_retry( + self, + timeout: Optional[float] = None, + *, + suppress_next_session_timeout_message: bool = False, + ): async def open_with_timeout(timeout: Optional[float] = None): if timeout is not None and timeout <= 0: raise OperationTimeoutError() @@ -415,13 +435,28 @@ async def open_with_timeout(timeout: Optional[float] = None): open_with_timeout, timeout=timeout, operation_requires_timeout=timeout is not None, + suppress_next_session_timeout_message=( + suppress_next_session_timeout_message + ), ) - async def _open_mgmt_link_with_retry(self, timeout: Optional[float] = None): + async def _open_mgmt_link_with_retry( + self, + timeout: Optional[float] = None, + *, + suppress_next_session_timeout_message: bool = False, + ): async def open_mgmt_link(timeout: Optional[float] = None): if timeout is not None and timeout <= 0: raise OperationTimeoutError() - await self._open() + start_time = time.monotonic() + if timeout is None: + await self._open() + else: + await self._open_with_timeout(timeout) + timeout -= time.monotonic() - start_time + if timeout <= 0: + raise OperationTimeoutError() return await self._amqp_transport.mgmt_client_setup_async( self._handler, node=self._mgmt_target.encode(self._config.encoding), @@ -432,6 +467,9 @@ async def open_mgmt_link(timeout: Optional[float] = None): open_mgmt_link, timeout=timeout, operation_requires_timeout=timeout is not None, + suppress_next_session_timeout_message=( + suppress_next_session_timeout_message + ), ) async def _close_handler(self): diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py index 5e88224c2457..6cb0c055d91b 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py @@ -882,13 +882,19 @@ async def delete_messages( ), } start_time = time.monotonic() - await self._open_with_retry(timeout=timeout) + await self._open_with_retry( + timeout=timeout, + suppress_next_session_timeout_message=True, + ) remaining_timeout = ( None if timeout is None else timeout - (time.monotonic() - start_time) ) if remaining_timeout is not None and remaining_timeout <= 0: raise OperationTimeoutError() - await self._open_mgmt_link_with_retry(timeout=remaining_timeout) + await self._open_mgmt_link_with_retry( + timeout=remaining_timeout, + suppress_next_session_timeout_message=True, + ) remaining_timeout = ( None if timeout is None else timeout - (time.monotonic() - start_time) ) diff --git a/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py b/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py index eac526bcf0d5..48c2320bac01 100644 --- a/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py +++ b/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py @@ -118,11 +118,15 @@ def _receiver(receiver_type, session_id=None): class TestDeleteMessages: @pytest.mark.parametrize( - "session_id,expects_session_advice", - [(None, False), (NEXT_AVAILABLE_SESSION, True)], + "session_id,suppress_session_advice,expects_session_advice", + [ + (None, False, False), + (NEXT_AVAILABLE_SESSION, False, True), + (NEXT_AVAILABLE_SESSION, True, False), + ], ) def test_setup_timeout_advice_only_applies_to_next_available_session( - self, session_id, expects_session_advice + self, session_id, suppress_session_advice, expects_session_advice ): receiver = _receiver(ServiceBusReceiver, session_id=session_id) receiver._config = MagicMock(retry_total=0) @@ -133,7 +137,10 @@ def time_out(): raise OperationTimeoutError() with pytest.raises(OperationTimeoutError) as exc_info: - receiver._do_retryable_operation(time_out) + receiver._do_retryable_operation( + time_out, + suppress_next_session_timeout_message=suppress_session_advice, + ) assert ("NEXT_AVAILABLE_SESSION" in str(exc_info.value)) is expects_session_advice @@ -160,8 +167,13 @@ def dispatch(operation, message, callback, **kwargs): cutoff.timestamp() * 1000 ) assert 0 < calls[0][3]["timeout"] <= 12 - receiver._open_with_retry.assert_called_once_with(timeout=12) + receiver._open_with_retry.assert_called_once_with( + timeout=12, suppress_next_session_timeout_message=True + ) receiver._open_mgmt_link_with_retry.assert_called_once() + setup_kwargs = receiver._open_mgmt_link_with_retry.call_args.kwargs + assert setup_kwargs["suppress_next_session_timeout_message"] is True + assert 0 < setup_kwargs["timeout"] <= 12 receiver._mgmt_request_response_with_retry.assert_not_called() def test_setup_failure_does_not_dispatch(self): @@ -193,7 +205,9 @@ def test_dispatch_failure_is_not_retried(self): with pytest.raises(RuntimeError, match="dispatch failed"): receiver.delete_messages(1) - receiver._open_with_retry.assert_called_once_with(timeout=None) + receiver._open_with_retry.assert_called_once_with( + timeout=None, suppress_next_session_timeout_message=True + ) receiver._mgmt_request_response.assert_called_once() receiver._mgmt_request_response_with_retry.assert_not_called() @@ -224,6 +238,29 @@ def test_dispatch_uses_remaining_timeout_and_stops_when_setup_exhausts_it(self): receiver._mgmt_request_response.assert_not_called() + def test_management_setup_shares_timeout_with_receiver_reopen(self): + receiver = _receiver(ServiceBusReceiver) + receiver._config = MagicMock(retry_total=0, encoding="UTF-8") + receiver._container_id = "receiver" + receiver._mgmt_target = "$management" + receiver._open_with_timeout = MagicMock() + receiver._amqp_transport.mgmt_client_setup = MagicMock() + + with patch( + "azure.servicebus._base_handler.time.monotonic", + side_effect=[10.0, 10.5], + ): + ServiceBusReceiver._open_mgmt_link_with_retry(receiver, timeout=2) + + receiver._open_with_timeout.assert_called_once() + receiver_timeout = receiver._open_with_timeout.call_args.args[0] + assert 0 < receiver_timeout <= 2 + receiver._amqp_transport.mgmt_client_setup.assert_called_once_with( + receiver._handler, + node=receiver._mgmt_target.encode("UTF-8"), + timeout=pytest.approx(receiver_timeout - 0.5), + ) + def test_forwards_session_id(self): receiver = _receiver(ServiceBusReceiver, session_id="session-a") captured = {} @@ -406,11 +443,15 @@ def test_operation_deadline_stops_before_another_dispatch(self): class TestDeleteMessagesAsync: @pytest.mark.asyncio @pytest.mark.parametrize( - "session_id,expects_session_advice", - [(None, False), (NEXT_AVAILABLE_SESSION, True)], + "session_id,suppress_session_advice,expects_session_advice", + [ + (None, False, False), + (NEXT_AVAILABLE_SESSION, False, True), + (NEXT_AVAILABLE_SESSION, True, False), + ], ) async def test_setup_timeout_advice_only_applies_to_next_available_session( - self, session_id, expects_session_advice + self, session_id, suppress_session_advice, expects_session_advice ): receiver = _receiver(AsyncServiceBusReceiver, session_id=session_id) receiver._config = MagicMock(retry_total=0) @@ -424,7 +465,10 @@ async def time_out(): receiver._handle_exception = handle_exception with pytest.raises(OperationTimeoutError) as exc_info: - await receiver._do_retryable_operation(time_out) + await receiver._do_retryable_operation( + time_out, + suppress_next_session_timeout_message=suppress_session_advice, + ) assert ("NEXT_AVAILABLE_SESSION" in str(exc_info.value)) is expects_session_advice @@ -454,8 +498,13 @@ async def dispatch(operation, message, callback, **kwargs): cutoff.timestamp() * 1000 ) assert 0 < calls[0][3]["timeout"] <= 12 - receiver._open_with_retry.assert_awaited_once_with(timeout=12) + receiver._open_with_retry.assert_awaited_once_with( + timeout=12, suppress_next_session_timeout_message=True + ) receiver._open_mgmt_link_with_retry.assert_awaited_once() + setup_kwargs = receiver._open_mgmt_link_with_retry.await_args.kwargs + assert setup_kwargs["suppress_next_session_timeout_message"] is True + assert 0 < setup_kwargs["timeout"] <= 12 receiver._mgmt_request_response_with_retry.assert_not_called() @pytest.mark.asyncio @@ -534,7 +583,9 @@ async def test_dispatch_failure_is_not_retried(self): with pytest.raises(RuntimeError, match="dispatch failed"): await receiver.delete_messages(1) - receiver._open_with_retry.assert_awaited_once_with(timeout=None) + receiver._open_with_retry.assert_awaited_once_with( + timeout=None, suppress_next_session_timeout_message=True + ) receiver._mgmt_request_response.assert_awaited_once() receiver._mgmt_request_response_with_retry.assert_not_called() @@ -568,6 +619,32 @@ async def test_dispatch_uses_remaining_timeout_and_stops_when_setup_exhausts_it( receiver._mgmt_request_response.assert_not_awaited() + @pytest.mark.asyncio + async def test_management_setup_shares_timeout_with_receiver_reopen(self): + receiver = _receiver(AsyncServiceBusReceiver) + receiver._config = MagicMock(retry_total=0, encoding="UTF-8") + receiver._container_id = "receiver" + receiver._mgmt_target = "$management" + receiver._open_with_timeout = AsyncMock() + receiver._amqp_transport.mgmt_client_setup_async = AsyncMock() + + with patch( + "azure.servicebus.aio._base_handler_async.time.monotonic", + side_effect=[10.0, 10.5], + ): + await AsyncServiceBusReceiver._open_mgmt_link_with_retry( + receiver, timeout=2 + ) + + receiver._open_with_timeout.assert_awaited_once() + receiver_timeout = receiver._open_with_timeout.await_args.args[0] + assert 0 < receiver_timeout <= 2 + receiver._amqp_transport.mgmt_client_setup_async.assert_awaited_once_with( + receiver._handler, + node=receiver._mgmt_target.encode("UTF-8"), + timeout=pytest.approx(receiver_timeout - 0.5), + ) + @pytest.mark.asyncio async def test_keeps_one_cutoff_and_stops_only_on_zero(self): receiver = _receiver(AsyncServiceBusReceiver) From ce37d9fd4a4164f5f51aa790bac8ef92c9d6829c Mon Sep 17 00:00:00 2001 From: Eldert Grootenboer Date: Thu, 3 Sep 2026 11:37:04 -0700 Subject: [PATCH 5/8] fix: recover failed management link setup --- .../servicebus/_pyamqp/aio/_client_async.py | 26 +++++---- .../azure/servicebus/_pyamqp/client.py | 24 ++++---- .../tests/unittests/test_batch_delete.py | 57 +++++++++++++++++++ 3 files changed, 84 insertions(+), 23 deletions(-) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/aio/_client_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/aio/_client_async.py index f1a86d15a6ca..219fee265d82 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/aio/_client_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/aio/_client_async.py @@ -385,14 +385,15 @@ async def open_mgmt_link_async(self, node: str = "$management", timeout: float = :rtype: ~pyamqp.aio.management_link_async.ManagementOperation """ start_time = time.monotonic() - async with self._mgmt_link_lock_async: - try: - mgmt_link = self._mgmt_links[node] - except KeyError: - mgmt_link = ManagementOperation(self._session, endpoint=node, **kwargs) - self._mgmt_links[node] = mgmt_link - await mgmt_link.open() + mgmt_link = None try: + async with self._mgmt_link_lock_async: + try: + mgmt_link = self._mgmt_links[node] + except KeyError: + mgmt_link = ManagementOperation(self._session, endpoint=node, **kwargs) + self._mgmt_links[node] = mgmt_link + await mgmt_link.open() while not await self.client_ready_async(): if timeout and time.monotonic() - start_time >= timeout: raise TimeoutError("Management link setup timed out.") @@ -403,11 +404,12 @@ async def open_mgmt_link_async(self, node: str = "$management", timeout: float = raise TimeoutError("Management link setup timed out.") await self._connection.listen(wait=False) return mgmt_link - except Exception: - async with self._mgmt_link_lock_async: - if self._mgmt_links.get(node) is mgmt_link: - self._mgmt_links.pop(node, None) - await mgmt_link.close() + except BaseException: + if mgmt_link is not None: + async with self._mgmt_link_lock_async: + if self._mgmt_links.get(node) is mgmt_link: + self._mgmt_links.pop(node, None) + await mgmt_link.close() raise diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/client.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/client.py index b4f5d0b52cae..65bc8bd152e4 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/client.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_pyamqp/client.py @@ -472,14 +472,15 @@ def open_mgmt_link(self, node: str = "$management", timeout: float = 0, **kwargs :rtype: ~pyamqp.management_link.ManagementOperation """ start_time = time.monotonic() - with self._mgmt_link_lock: - try: - mgmt_link = self._mgmt_links[node] - except KeyError: - mgmt_link = ManagementOperation(self._session, endpoint=node, **kwargs) - self._mgmt_links[node] = mgmt_link - mgmt_link.open() + mgmt_link = None try: + with self._mgmt_link_lock: + try: + mgmt_link = self._mgmt_links[node] + except KeyError: + mgmt_link = ManagementOperation(self._session, endpoint=node, **kwargs) + self._mgmt_links[node] = mgmt_link + mgmt_link.open() while not self.client_ready(): if timeout and time.monotonic() - start_time >= timeout: raise TimeoutError("Management link setup timed out.") @@ -491,10 +492,11 @@ def open_mgmt_link(self, node: str = "$management", timeout: float = 0, **kwargs self._connection.listen(wait=False) return mgmt_link except Exception: - with self._mgmt_link_lock: - if self._mgmt_links.get(node) is mgmt_link: - self._mgmt_links.pop(node, None) - mgmt_link.close() + if mgmt_link is not None: + with self._mgmt_link_lock: + if self._mgmt_links.get(node) is mgmt_link: + self._mgmt_links.pop(node, None) + mgmt_link.close() raise diff --git a/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py b/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py index 48c2320bac01..d3976c8b931c 100644 --- a/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py +++ b/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py @@ -3,6 +3,8 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # ------------------------------------------------------------------------- +import asyncio +import threading from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch @@ -22,6 +24,8 @@ ) from azure.servicebus._common import mgmt_handlers from azure.servicebus._pyamqp._encode import encode_payload +from azure.servicebus._pyamqp.client import AMQPClient +from azure.servicebus._pyamqp.aio._client_async import AMQPClientAsync from azure.servicebus._transport._pyamqp_transport import PyamqpTransport from azure.servicebus.exceptions import OperationTimeoutError @@ -116,6 +120,59 @@ def _receiver(receiver_type, session_id=None): return receiver +def test_failed_management_link_open_is_evicted_before_retry(): + client = object.__new__(AMQPClient) + client._mgmt_links = {} + client._mgmt_link_lock = threading.Lock() + client._session = MagicMock() + client.client_ready = MagicMock(return_value=True) + failed_link = MagicMock() + failed_link.open.side_effect = RuntimeError("attach failed") + ready_link = MagicMock() + ready_link.ready.return_value = True + + with patch( + "azure.servicebus._pyamqp.client.ManagementOperation", + side_effect=[failed_link, ready_link], + ) as operation_type: + with pytest.raises(RuntimeError, match="attach failed"): + client.open_mgmt_link() + assert client._mgmt_links == {} + failed_link.close.assert_called_once() + + assert client.open_mgmt_link() is ready_link + + assert operation_type.call_count == 2 + + +@pytest.mark.asyncio +async def test_cancelled_management_link_open_is_evicted_before_retry(): + client = object.__new__(AMQPClientAsync) + client._mgmt_links = {} + client._mgmt_link_lock_async = asyncio.Lock() + client._session = MagicMock() + client.client_ready_async = AsyncMock(return_value=True) + failed_link = MagicMock() + failed_link.open = AsyncMock(side_effect=asyncio.CancelledError()) + failed_link.close = AsyncMock() + ready_link = MagicMock() + ready_link.open = AsyncMock() + ready_link.ready = AsyncMock(return_value=True) + + with patch( + "azure.servicebus._pyamqp.aio._client_async.ManagementOperation", + side_effect=[failed_link, ready_link], + ) as operation_type: + with pytest.raises(asyncio.CancelledError): + await client.open_mgmt_link_async() + assert client._mgmt_links == {} + failed_link.close.assert_awaited_once() + + assert await client.open_mgmt_link_async() is ready_link + + assert operation_type.call_count == 2 + + class TestDeleteMessages: @pytest.mark.parametrize( "session_id,suppress_session_advice,expects_session_advice", From a1c6bfd44a855e7b344ff310b80e836aa99b2f14 Mon Sep 17 00:00:00 2001 From: Eldert Grootenboer Date: Thu, 3 Sep 2026 13:05:05 -0700 Subject: [PATCH 6/8] fix: enforce batch delete operation deadline --- .../azure/servicebus/_common/mgmt_handlers.py | 3 -- .../azure/servicebus/_servicebus_receiver.py | 14 +++-- .../aio/_servicebus_receiver_async.py | 38 ++++++++++---- .../tests/unittests/test_batch_delete.py | 52 ++++++++++++++++++- 4 files changed, 89 insertions(+), 18 deletions(-) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/mgmt_handlers.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/mgmt_handlers.py index 4291fdbd408e..7ec99714d7eb 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/mgmt_handlers.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/mgmt_handlers.py @@ -83,9 +83,6 @@ def batch_delete_op( # pylint: disable=inconsistent-return-statements ): raise ValueError("Batch delete response did not contain a valid message-count.") return deleted_count - if status_code == 204: - return 0 - amqp_transport.handle_amqp_mgmt_error( _LOGGER, "Batch delete messages failed.", condition, description, status_code ) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py index 8005c4680388..93b13a780256 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py @@ -402,17 +402,25 @@ def _open(self, timeout: Optional[float] = None) -> None: # pylint: disable=protected-access if self._running: return + deadline = None if timeout is None else time.monotonic() + timeout + + def check_deadline() -> None: + if deadline is not None and time.monotonic() >= deadline: + raise OperationTimeoutError() + if self._handler and not self._handler._shutdown: self._handler.close() + check_deadline() auth = None if self._connection else create_authentication(self) + check_deadline() self._create_handler(auth) + check_deadline() try: - deadline = None if timeout is None else time.monotonic() + timeout self._handler.open(connection=self._connection) + check_deadline() while not self._handler.client_ready(): - if deadline is not None and time.monotonic() >= deadline: - raise OperationTimeoutError() + check_deadline() time.sleep(0.05) self._running = True except: diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py index 6cb0c055d91b..f271590219d2 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py @@ -403,17 +403,35 @@ async def _open(self, timeout: Optional[float] = None) -> None: # pylint: disable=protected-access if self._running: return - if self._handler and not self._handler._shutdown: - await self._handler.close_async() - auth = None if self._connection else (await create_authentication(self)) - self._create_handler(auth) + deadline = None if timeout is None else time.monotonic() + timeout + + def remaining_timeout() -> Optional[float]: + if deadline is None: + return None + remaining = deadline - time.monotonic() + if remaining <= 0: + raise OperationTimeoutError() + return remaining + + async def wait_for_setup(awaitable): + remaining = remaining_timeout() + if remaining is None: + return await awaitable + try: + return await asyncio.wait_for(awaitable, timeout=remaining) + except asyncio.TimeoutError as exception: + raise OperationTimeoutError() from exception + try: - deadline = None if timeout is None else time.monotonic() + timeout - await self._handler.open_async(connection=self._connection) - while not await self._handler.client_ready_async(): - if deadline is not None and time.monotonic() >= deadline: - raise OperationTimeoutError() - await asyncio.sleep(0.05) + if self._handler and not self._handler._shutdown: + await wait_for_setup(self._handler.close_async()) + auth = None if self._connection else (await wait_for_setup(create_authentication(self))) + self._create_handler(auth) + remaining_timeout() + await wait_for_setup(self._handler.open_async(connection=self._connection)) + while not await wait_for_setup(self._handler.client_ready_async()): + remaining = remaining_timeout() + await asyncio.sleep(0.05 if remaining is None else min(0.05, remaining)) self._running = True except: await self._close_handler() diff --git a/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py b/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py index d3976c8b931c..b9b498a29f21 100644 --- a/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py +++ b/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py @@ -52,15 +52,17 @@ def test_batch_delete_request_keys_encode_as_amqp_strings(): assert b"\xa0\x11enqueued-time-utc" not in encoded -def test_batch_delete_handler_returns_actual_count_and_only_maps_204_to_zero(): +def test_batch_delete_handler_returns_actual_count_and_rejects_204(): message = MagicMock() message.value = {b"message-count": 2} message.application_properties = {} transport = MagicMock() assert mgmt_handlers.batch_delete_op(200, message, None, transport, 10) == 2 - assert mgmt_handlers.batch_delete_op(204, message, None, transport, 10) == 0 + mgmt_handlers.batch_delete_op(204, message, "missing count", transport, 10) + transport.handle_amqp_mgmt_error.assert_called_once() + transport.reset_mock() mgmt_handlers.batch_delete_op(202, message, "unexpected", transport, 10) transport.handle_amqp_mgmt_error.assert_called_once() @@ -366,6 +368,25 @@ def test_open_readiness_respects_timeout(self): with pytest.raises(OperationTimeoutError): receiver._open(timeout=0.5) + def test_open_authentication_consumes_timeout(self): + receiver = _receiver(ServiceBusReceiver) + receiver._running = False + receiver._connection = None + receiver._handler = None + receiver._create_handler = MagicMock() + + with patch( + "azure.servicebus._servicebus_receiver.create_authentication", + return_value=None, + ), patch( + "azure.servicebus._servicebus_receiver.time.monotonic", + side_effect=[10.0, 11.0], + ): + with pytest.raises(OperationTimeoutError): + receiver._open(timeout=0.5) + + receiver._create_handler.assert_not_called() + def test_supports_premium_count(self): receiver = _receiver(ServiceBusReceiver) receiver._mgmt_request_response = MagicMock(return_value=4000) @@ -630,6 +651,33 @@ async def test_open_readiness_respects_timeout(self): with pytest.raises(OperationTimeoutError): await receiver._open(timeout=0.5) + @pytest.mark.asyncio + async def test_open_authentication_consumes_timeout(self): + receiver = _receiver(AsyncServiceBusReceiver) + receiver._running = False + receiver._connection = None + receiver._handler = None + receiver._create_handler = MagicMock() + + async def wait_for_setup(awaitable, timeout): + del timeout + return await awaitable + + with patch( + "azure.servicebus.aio._servicebus_receiver_async.create_authentication", + new=AsyncMock(return_value=None), + ), patch( + "azure.servicebus.aio._servicebus_receiver_async.asyncio.wait_for", + side_effect=wait_for_setup, + ), patch( + "azure.servicebus.aio._servicebus_receiver_async.time.monotonic", + side_effect=[10.0, 10.1, 11.0], + ): + with pytest.raises(OperationTimeoutError): + await receiver._open(timeout=0.5) + + receiver._create_handler.assert_called_once_with(None) + @pytest.mark.asyncio async def test_dispatch_failure_is_not_retried(self): receiver = _receiver(AsyncServiceBusReceiver) From b3836d593674f2f57a6da2a7134b62dda2c6a14a Mon Sep 17 00:00:00 2001 From: Eldert Grootenboer Date: Thu, 3 Sep 2026 13:21:22 -0700 Subject: [PATCH 7/8] fix: defer async setup coroutine creation --- .../aio/_servicebus_receiver_async.py | 11 +++++----- .../tests/unittests/test_batch_delete.py | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py index f271590219d2..7f3b6e7fabe6 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_receiver_async.py @@ -413,8 +413,9 @@ def remaining_timeout() -> Optional[float]: raise OperationTimeoutError() return remaining - async def wait_for_setup(awaitable): + async def wait_for_setup(operation, *args, **kwargs): remaining = remaining_timeout() + awaitable = operation(*args, **kwargs) if remaining is None: return await awaitable try: @@ -424,12 +425,12 @@ async def wait_for_setup(awaitable): try: if self._handler and not self._handler._shutdown: - await wait_for_setup(self._handler.close_async()) - auth = None if self._connection else (await wait_for_setup(create_authentication(self))) + await wait_for_setup(self._handler.close_async) + auth = None if self._connection else (await wait_for_setup(create_authentication, self)) self._create_handler(auth) remaining_timeout() - await wait_for_setup(self._handler.open_async(connection=self._connection)) - while not await wait_for_setup(self._handler.client_ready_async()): + await wait_for_setup(self._handler.open_async, connection=self._connection) + while not await wait_for_setup(self._handler.client_ready_async): remaining = remaining_timeout() await asyncio.sleep(0.05 if remaining is None else min(0.05, remaining)) self._running = True diff --git a/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py b/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py index b9b498a29f21..14177bd47e52 100644 --- a/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py +++ b/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py @@ -678,6 +678,26 @@ async def wait_for_setup(awaitable, timeout): receiver._create_handler.assert_called_once_with(None) + @pytest.mark.asyncio + async def test_open_does_not_create_awaitable_after_timeout(self): + receiver = _receiver(AsyncServiceBusReceiver) + receiver._running = False + receiver._connection = None + receiver._handler = None + authentication = AsyncMock(return_value=None) + + with patch( + "azure.servicebus.aio._servicebus_receiver_async.create_authentication", + new=authentication, + ), patch( + "azure.servicebus.aio._servicebus_receiver_async.time.monotonic", + side_effect=[10.0, 11.0], + ): + with pytest.raises(OperationTimeoutError): + await receiver._open(timeout=0.5) + + authentication.assert_not_called() + @pytest.mark.asyncio async def test_dispatch_failure_is_not_retried(self): receiver = _receiver(AsyncServiceBusReceiver) From d3967bfc911400fc82ce2042fe13a3b197c80118 Mon Sep 17 00:00:00 2001 From: Eldert Grootenboer Date: Thu, 3 Sep 2026 14:06:49 -0700 Subject: [PATCH 8/8] fix: map management setup timeouts --- .../_transport/_pyamqp_transport.py | 5 ++++- .../aio/_transport/_pyamqp_transport_async.py | 5 ++++- .../tests/unittests/test_batch_delete.py | 20 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py index 9c5632ea8de8..5b15342f7cef 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_transport/_pyamqp_transport.py @@ -1090,7 +1090,10 @@ def mgmt_client_setup(mgmt_client: "AMQPClient", *, node: str, timeout: int) -> :keyword str node: Management target. :keyword int timeout: Timeout in seconds. """ - mgmt_client.open_mgmt_link(node=node, timeout=timeout or 0) + try: + mgmt_client.open_mgmt_link(node=node, timeout=timeout or 0) + except TimeoutError as exception: + raise OperationTimeoutError(error=exception) from exception @staticmethod def _handle_amqp_exception_with_condition( diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_pyamqp_transport_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_pyamqp_transport_async.py index 81da9e2d029a..d035fcc2ffdc 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_pyamqp_transport_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_transport/_pyamqp_transport_async.py @@ -537,4 +537,7 @@ async def mgmt_client_setup_async(mgmt_client: "AMQPClientAsync", *, node: str, :keyword str node: Management target. :keyword int timeout: Timeout in seconds. """ - await mgmt_client.open_mgmt_link_async(node=node, timeout=timeout or 0) + try: + await mgmt_client.open_mgmt_link_async(node=node, timeout=timeout or 0) + except TimeoutError as exception: + raise OperationTimeoutError(error=exception) from exception diff --git a/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py b/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py index 14177bd47e52..00afd62ba0cd 100644 --- a/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py +++ b/sdk/servicebus/azure-servicebus/tests/unittests/test_batch_delete.py @@ -27,6 +27,7 @@ from azure.servicebus._pyamqp.client import AMQPClient from azure.servicebus._pyamqp.aio._client_async import AMQPClientAsync from azure.servicebus._transport._pyamqp_transport import PyamqpTransport +from azure.servicebus.aio._transport._pyamqp_transport_async import PyamqpTransportAsync from azure.servicebus.exceptions import OperationTimeoutError @@ -147,6 +148,14 @@ def test_failed_management_link_open_is_evicted_before_retry(): assert operation_type.call_count == 2 +def test_management_link_setup_converts_timeout_error(): + client = MagicMock() + client.open_mgmt_link.side_effect = TimeoutError("setup timed out") + + with pytest.raises(OperationTimeoutError): + PyamqpTransport.mgmt_client_setup(client, node="$management", timeout=1) + + @pytest.mark.asyncio async def test_cancelled_management_link_open_is_evicted_before_retry(): client = object.__new__(AMQPClientAsync) @@ -175,6 +184,17 @@ async def test_cancelled_management_link_open_is_evicted_before_retry(): assert operation_type.call_count == 2 +@pytest.mark.asyncio +async def test_async_management_link_setup_converts_timeout_error(): + client = MagicMock() + client.open_mgmt_link_async = AsyncMock(side_effect=TimeoutError("setup timed out")) + + with pytest.raises(OperationTimeoutError): + await PyamqpTransportAsync.mgmt_client_setup_async( + client, node="$management", timeout=1 + ) + + class TestDeleteMessages: @pytest.mark.parametrize( "session_id,suppress_session_advice,expects_session_advice",