diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 42f0f90..f2d41d1 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,40 +1,40 @@ -name: .NET - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - # Azurite emulates Azure Storage so the integration tests run without a - # live storage account. It is started with docker run rather than as a - # service container because it needs --skipApiVersionCheck: the Azure SDK - # sends an API version newer than Azurite knows about and Azurite rejects - # it with a 400 otherwise. Only the queue port (10001) is mapped. - - name: Start Azurite - run: | - docker run -d --name azurite -p 10001:10001 \ - mcr.microsoft.com/azure-storage/azurite \ - azurite --queueHost 0.0.0.0 --blobHost 0.0.0.0 --tableHost 0.0.0.0 --skipApiVersionCheck --silent - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 8.0.x - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build --no-restore - - name: Test - env: - # UseDevelopmentStorage=true targets the Azurite emulator above. - Storage__ConnectionString: "UseDevelopmentStorage=true" - Storage__QueueName: "integration-test-queue" - run: dotnet test --no-build --verbosity normal -p:ParallelizeTestCollections=false \ No newline at end of file +name: .NET + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + # Azurite emulates Azure Storage so the integration tests run without a + # live storage account. It is started with docker run rather than as a + # service container because it needs --skipApiVersionCheck: the Azure SDK + # sends an API version newer than Azurite knows about and Azurite rejects + # it with a 400 otherwise. Only the queue port (10001) is mapped. + - name: Start Azurite + run: | + docker run -d --name azurite -p 10001:10001 \ + mcr.microsoft.com/azure-storage/azurite \ + azurite --queueHost 0.0.0.0 --blobHost 0.0.0.0 --tableHost 0.0.0.0 --skipApiVersionCheck --silent + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 10.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Test + env: + # UseDevelopmentStorage=true targets the Azurite emulator above. + Storage__ConnectionString: "UseDevelopmentStorage=true" + Storage__QueueName: "integration-test-queue" + run: dotnet test --no-build --verbosity normal -p:ParallelizeTestCollections=false diff --git a/.github/workflows/nuget.yml b/.github/workflows/nuget.yml index 7a174cc..8742a71 100644 --- a/.github/workflows/nuget.yml +++ b/.github/workflows/nuget.yml @@ -1,39 +1,39 @@ -name: Nuget - -on: - push: - tags: - - 'v*' - -env: - PACKAGE_OUTPUT: ${{ github.workspace }}/package - NUGET_URL: https://api.nuget.org/v3/index.json - -jobs: - deploy: - name: Deploy - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 8.0.x - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --no-restore --configuration Release - - - name: Get Version - id: version - uses: battila7/get-version-action@v2 - - - name: Package Project - run: dotnet pack --no-restore --no-build --configuration Release --include-symbols -p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ${{ env.PACKAGE_OUTPUT }} - - - name: Publish Package - run: dotnet nuget push ${{ env.PACKAGE_OUTPUT }}/*.nupkg -k ${{ secrets.NUGET_KEY }} -s ${{ env.NUGET_URL }} --skip-duplicate \ No newline at end of file +name: Nuget + +on: + push: + tags: + - 'v*' + +env: + PACKAGE_OUTPUT: ${{ github.workspace }}/package + NUGET_URL: https://api.nuget.org/v3/index.json + +jobs: + deploy: + name: Deploy + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 10.0.x + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Get Version + id: version + uses: battila7/get-version-action@v2 + + - name: Package Project + run: dotnet pack --no-restore --no-build --configuration Release --include-symbols -p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ${{ env.PACKAGE_OUTPUT }} + + - name: Publish Package + run: dotnet nuget push ${{ env.PACKAGE_OUTPUT }}/*.nupkg -k ${{ secrets.NUGET_KEY }} -s ${{ env.NUGET_URL }} --skip-duplicate diff --git a/README.md b/README.md index 35a740f..d8e94d6 100644 --- a/README.md +++ b/README.md @@ -8,311 +8,227 @@ [![.NET](https://github.com/jasonshave/AzureStorage.QueueService/actions/workflows/dotnet.yml/badge.svg)](https://github.com/jasonshave/AzureStorage.QueueService/actions/workflows/dotnet.yml) [![Nuget](https://github.com/jasonshave/AzureStorage.QueueService/actions/workflows/nuget.yml/badge.svg)](https://github.com/jasonshave/AzureStorage.QueueService/actions/workflows/nuget.yml) -This project abstracts away the complexities of using the Azure Storage account's queue feature targeting .NET 6 and higher. +Typed sending, receiving and processing for Azure Storage queues on .NET 10 and later, registered the way `IHttpClientFactory` registers clients: configure named queues once at startup, then inject them or run a processor against them. -## Pre-requisites +Still on .NET 8? Use the 2.x package line, which stays on .NET 8 and keeps receiving fixes. -You will need to create an Azure Storage account in the Azure portal using a unique name, then create a queue, and finally obtain your connection string. +- **Send** with `IQueueSender`: your object goes in, JSON goes on the queue. +- **Receive** with `IQueueReceiver`: each message comes back typed, with its identity, and you complete, abandon or dead-letter it. +- **Process** with `AddProcessor()`: the library runs the loop — backoff, lease renewal, retries, a poison queue, cancellation and graceful shutdown — and hands each message to a handler class resolved from its own dependency-injection scope. -1. [Create a storage account](https://docs.microsoft.com/en-us/azure/storage/common/storage-account-create?tabs=azure-portal) in your Azure portal. -2. [Obtain your connection string](https://docs.microsoft.com/en-us/azure/storage/common/storage-account-keys-manage?tabs=azure-portal) from the Azure portal. +Upgrading from 2.x? 3.0 is a clean break — see [Upgrading from 2.x](#upgrading-from-2x) for the mapping. -## Message handling behavior +## Install -- Sending a message will automatically serialize the payload to `BinaryData`. -- By default one message is pulled when `ReceiveMessagesAsync` is called, however this is configurable. -- If your handler does not throw, messages are automatically removed from the queue otherwise the message is returned to the queue for delivery again. -- `ReceiveMessagesAsync` never throws for failures in the queue read, the deserialization, your handler, the delete, or your exception handler. Every failure is logged and passed to your exception handler, and the remaining messages in the batch are still processed. The only exception that escapes is `OperationCanceledException` for your own cancellation token, so a transient storage error cannot take down your host. -- A received message is hidden from other consumers for the *visibility timeout* (30 seconds unless you set `VisibilityTimeout`). If your handler runs longer than that, the message becomes visible again, another consumer can pick it up, and the delete on this side fails and is reported to your exception handler. Set `VisibilityTimeout` longer than your slowest handler multiplied by the number of messages you receive per call. -- A message that can never be processed (for example, invalid JSON) is redelivered every time its visibility timeout expires. Watch for repeated `DeserializationException`s in your exception handler and in the log, which includes the message's dequeue count. -- Automatic deserialization uses the `System.Text.Json` deserialization behavior. This can be overridden by specifying your own `JsonSerializerOptions` as seen below. -- You can 'peek' messages using `PeekMessages` which returns a collection but doesn't remove them from the queue. +Add the NuGet package [`AzureStorage.QueueService`](https://www.nuget.org/packages/AzureStorage.QueueService). -## Configuration +You need an Azure Storage account and either its connection string or a queue URI plus an identity that can read and write the queue. The library can create the queue for you (`CreateIfNotExists`); the processor also creates a `-poison` queue the first time it dead-letters a message. -1. Add the NuGet package [`AzureStorage.QueueService`](https://www.nuget.org/packages/AzureStorage.QueueService) to your .NET project -2. Set your `ConnectionString` and `QueueName` properties in your [.NET User Secrets store](https://docs.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows), `appsettings.json`, or anywhere your `IConfiguration` provider can look for the `QueueClientSettings`. For example: +## Configure clients - ```json - { - "QueueClientSettings" : { - "ConnectionString": "[your_connection_string]", - "QueueName": "[your_queue_name]", - "CreateIfNotExists": true, - "VisibilityTimeout": "00:02:00" - } - } - ``` - - or set the endpoint URI which inclues the queue name and use a Token Credential during startup: - - ```json - { - "QueueClientSettings": { - "EndpointUri": "https://somestorageaccount.queue.corewindows.net/myqueuename", - } - } - ``` - - ```csharp - services.AddAzureStorageQueueClient(x => - x.AddDefaultClient(y => - { - y.EndpointUri = new Uri(builder.Configuration["SomeKey:EndpointUri"]), - y.TokenCredential = new DefaultAzureCredential(), - })); - ``` +```csharp +builder.Services.AddAzureStorageQueueClient(queues => +{ + // The default client, bound from configuration + queues.AddDefaultClient(builder.Configuration.GetSection("Queues:Notifications")); -4. You can create your queue in advance or allow the library to create it during runtime by setting the `CreateIfNotExists` property to `true`. The queue is created the first time the client is used, not when it is resolved from dependency injection. + // A named client, configured in code and given its own serializer settings + queues.AddClient("orders", settings => + { + settings.EndpointUri = new Uri("https://myaccount.queue.core.windows.net/orders"); + settings.TokenCredential = new DefaultAzureCredential(); + settings.CreateIfNotExists = true; + }) + .UseJsonSerializer(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + // Fail host startup if any client is missing its connection details + queues.ValidateOnStart(); +}); +``` -### Per-client settings +```json +{ + "Queues": { + "Notifications": { + "ConnectionString": "[your connection string]", + "QueueName": "notifications", + "CreateIfNotExists": true, + "VisibilityTimeout": "00:02:00" + } + } +} +``` -Every client has its own `QueueClientSettings`: +### Settings | Setting | What it does | |---|---| | `ConnectionString` + `QueueName` | Connect with a storage account connection string. | -| `EndpointUri` + `TokenCredential` | Connect with an identity instead. The URI includes the queue name. | -| `CreateIfNotExists` | Create the queue on first use if it does not exist. | -| `VisibilityTimeout` | How long a received message is hidden from other consumers while your handler runs. Defaults to 30 seconds. | -| `SerializerOptions` | `JsonSerializerOptions` for this client only, used for both sending and receiving. | -| `ClientOptions` | `QueueClientOptions` for the underlying Azure SDK client: retry policy, diagnostics, and `MessageEncoding`. Set `MessageEncoding = QueueMessageEncoding.Base64` for queues shared with Azure Functions or older SDKs. | - -## Configure dependency injection - -The library has been updated to handle both a default client and a named-client experience similar to how the .NET `IHttpClientFactory` works. This section outlines both ways you can configure dependency injection to either use the default client or a named client in case you have more than one queue to pull from. - -### Add the default client - -Use the `AddAzureStorageQueueClient()` method and specify the settings for the `ConnectionString` and the `QueueName` or use the `IConfiguration` binder to bind with a JSON configuration as shown above. +| `EndpointUri` + `TokenCredential` | Connect with an identity instead. The URI includes the queue name; a `QueueName` that says something different is rejected. | +| `CreateIfNotExists` | Create the queue on first use if it does not exist. Happens on the first operation, not at startup. | +| `VisibilityTimeout` | How long a message received with `ReceiveAsync` stays hidden when the call does not say. Default 30 seconds. A processor uses its own setting. | +| `PoisonQueueName` | Where dead-lettered messages go. Default: the queue name plus `-poison`. Set it when the queue name is longer than 56 characters (Azure's limit is 63) or to share one poison queue. | +| `ClientOptions` | `QueueClientOptions` for the Azure SDK client: retry policy, diagnostics, and `MessageEncoding`. | -```csharp -services.AddAzureStorageQueueClient(x => - x.AddDefaultClient(y => - { - y.ConnectionString = "[your_connection_string]"; - y.QueueName = "[your_queue_name]"; - })); -``` - -```csharp -// get configuration from IConfiguration binder -services.AddAzureStorageQueueClient(x => - x.AddDefaultClient(y => Configuration.Bind(nameof(QueueClientSettings), y))); -``` - -### Add a named client - -Use the `AddAzureStorageQueueClient()` method with the `AddClient()` method to add and configure different queue clients which can be obtained using the `IQueueClientFactory` and the `GetQueueClient()` method. - -```csharp -services.AddAzureStorageQueueClient(x => - x.AddClient("MyClient1", y => - { - y.ConnectionString = "[your_connection_string]"; - y.QueueName = "[your_queue_name]"; - })); -``` +Registering the same name twice adds to its configuration. `AddClient` and `AddDefaultClient` return a builder for that one client with `Configure(...)`, `UseSerializer(...)`, `UseJsonSerializer(...)` and `AddProcessor<,>(...)`; other packages can add their own extension methods to it. -```csharp -// get configuration from IConfiguration binder -services.AddAzureStorageQueueClient(x => - x.AddClient("MyClient1", y => Configuration.Bind(nameof(QueueClientSettings), y))); +### Queues shared with Azure Functions or older SDKs -// or bind a configuration section directly -services.AddAzureStorageQueueClient(x => - x.AddClient("MyClient1", Configuration.GetSection("Queues:MyClient1"))); -``` +Those write Base64-encoded messages. Tell the SDK, and everything else is unchanged. A message on such a queue that is *not* valid Base64 does not fail the receive: it comes back with `Error` set alongside the healthy messages, so it can be dead-lettered like any other bad message. ```csharp -// add multiple named clients and a default client -services.AddAzureStorageQueueClient(x => +queues.AddClient("legacy", settings => { - x.AddClient("MyClient1", y => Configuration.Bind(nameof(QueueClientSettings), y)); - x.AddClient("MyClient2", y => - { - y.ConnectionString = "[your_connection_string]"; - y.QueueName = "[your_queue_name]"; - }); - x.AddDefaultClient(y => Configuration.Bind(nameof(DefaultQueueClientSettings), y)); + settings.ConnectionString = "..."; + settings.QueueName = "legacy"; + settings.ClientOptions = new QueueClientOptions { MessageEncoding = QueueMessageEncoding.Base64 }; }); ``` -## Using the IQueueClientFactory +## Get a client -### Example 1: Get a default queue client +Inject the default client as `IStorageQueue` (everything), `IQueueSender` (send only) or `IQueueReceiver` (receive only). Named clients are keyed services: ```csharp -// inject the IQueueClientFactory and get a default client -public class MyClass +public class OrderService( + IQueueSender notifications, // the default client + [FromKeyedServices("orders")] IStorageQueue orders) // a named client { - private readonly AzureStorageQueueClient _queueClient; - - public MyClass(IQueueClientFactory queueClientFactory) - { - _queueClient = queueClientFactory.GetQueueClient(); - } } ``` -### Example 2: Get a named client +Or ask the factory: `IQueueClientFactory.GetQueue()` / `GetQueue("orders")`. Clients are created once and shared for the life of the process; the SDK client underneath is thread-safe. -```csharp -// inject the IQueueClientFactory and get a named client -public class MyClass -{ - private readonly AzureStorageQueueClient _queueClient; +## Send - public MyClass(IQueueClientFactory queueClientFactory) - { - _queueClient = queueClientFactory.GetQueueClient("MyClient1"); - } -} +```csharp +await notifications.SendAsync(new Notification("hello")); +await notifications.SendAsync(new Notification("later"), new SendOptions(VisibilityTimeout: TimeSpan.FromMinutes(5))); ``` -### Example 3: Inject a client directly +The return value is the SDK's `SendReceipt`. Failures are logged and thrown. + +## Process messages (recommended) -Registered clients are also available from dependency injection without going through the factory. The default client is a plain singleton; named clients are keyed services. +Write a handler and register it against a client. A new handler is resolved from a new dependency-injection scope for every message, so scoped services such as a `DbContext` work exactly as they do in a controller. ```csharp -public class MyClass +public class OrderHandler(OrdersDbContext db) : IQueueMessageHandler { - public MyClass( - IAzureStorageQueueClient defaultClient, - [FromKeyedServices("MyClient1")] IAzureStorageQueueClient namedClient) + public async ValueTask HandleAsync(QueueMessage message, CancellationToken cancellationToken) { + await db.Fulfil(message.Body!, cancellationToken); + // Return and the message is completed. Throw and it is retried later. } } -``` - -Depend on `IAzureStorageQueueClient` rather than the concrete `AzureStorageQueueClient` if you want to substitute a fake in your own unit tests. - -## Sending messages to an Azure storage account queue - -The following example shows the .NET Worker Service template where a `BackgroundService` sends a message every five seconds. Use `BackgroundService.ExecuteAsync` for loops like this rather than `IHostedService.StartAsync`: the host waits for `StartAsync` to return before it finishes starting, so a loop there blocks startup. - -1. Inject the `IQueueClientFactory` interface and use as follows: - ```csharp - public class Sender : BackgroundService - { - private readonly AzureStorageQueueClient _queueClient; - - public Sender(IQueueClientFactory queueClientFactory) => _queueClient = queueClientFactory.GetQueueClient(); - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - var myMessage = new MyMessage("Test"); - await _queueClient.SendMessageAsync(myMessage, cancellationToken: stoppingToken); - await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); - } - } - } - ``` - -## Receiving and handling messages from an Azure storage account queue - -The following example shows the .NET Worker Service template where a `BackgroundService` polls the queue repeatedly. Your message handler receives the deserialized payload and the queue's metadata; your exception handler receives anything that went wrong while receiving, deserializing, handling or deleting a message. - -`ReceiveMessagesAsync` reports failures through your exception handler instead of throwing, so the loop below keeps running through transient storage errors. The `try`/`catch` is still worth having: it protects you from anything thrown by your own code outside the handlers, and an unhandled exception in `ExecuteAsync` stops the whole host by default in .NET 6 and later. +builder.Services.AddAzureStorageQueueClient(queues => + queues.AddClient("orders", builder.Configuration.GetSection("Queues:Orders")) + .AddProcessor(options => + { + options.MaxConcurrentMessages = 4; + options.MaxDequeueCount = 5; + })); +``` -1. Inject the `IQueueClientFactory` interface and use as follows: +A processor is `MaxConcurrentMessages` workers; each receives one message, delivers it, and goes back for the next. What it does for you: + +- **Polls with backoff.** An idle worker polls at a growing interval (1 s doubling to 30 s by default) that resets as soon as a message arrives. A failed receive — storage down, the queue missing, no permission — is logged and backed off the same way. Nothing at runtime stops the host. +- **Keeps the lease alive.** While your handler runs, the message's visibility timeout is renewed, which closes the avoidable window where a slow handler lets another consumer take the same message. Delivery is still at-least-once — a lease can lapse, a completion can fail after the work is done — so handlers should tolerate seeing a message twice. +- **Retries with a delay.** A handler that throws abandons the message with a growing delay (5 s, 10 s, 20 s, ... up to 5 minutes), logged with the message id and attempt count. A handler whose constructor throws is treated the same way, not swallowed. +- **Dead-letters poison messages.** A message delivered more than `MaxDequeueCount` times, or one whose body cannot be decoded or deserialized, is moved to the poison queue — body unchanged, so it can be moved straight back to replay it — without calling the handler. If that move fails, the message is abandoned instead, never dropped. +- **Lets the handler take over.** Call `message.CompleteAsync()`, `AbandonAsync()` or `DeadLetterAsync()` yourself and the processor leaves it alone; `message.Settlement` says what happened. +- **Shuts down cleanly.** On host shutdown it stops receiving, gives running handlers `ShutdownDrainTimeout` (30 s) to finish, then cancels their token and stops renewing their leases. A handler that ignores its token gets the same time again, after which shutdown stops waiting for it. All of that happens even if the host's own shutdown deadline cuts the drain short. Unfinished messages become visible again when their lease lapses. +- **Starts safely.** A processor whose client is misconfigured fails host startup, on purpose. Polling begins only when the host reports that the whole application has started, so a slow startup does not hold leases and a startup that fails part-way never polls at all. + +| Option | Default | What it does | +|---|---|---| +| `MaxConcurrentMessages` | 1 | Workers, each handling one message at a time in its own scope. | +| `VisibilityTimeout` | 1 minute | How long each received message is hidden (1 second to 7 days, whole seconds); renewed while the handler runs. | +| `RenewVisibility` | true | Renew the lease at half the timeout while the handler runs. | +| `AutoComplete` | true | Complete on return, abandon on throw. Off, the handler must settle every message. | +| `MaxDequeueCount` | 5 | Dead-letter a message delivered more times than this. | +| `DeadLetterOnDeserializationFailure` | true | Dead-letter a message whose body cannot be read. | +| `RetryDelay` | 5 s × 2ⁿ, max 5 min | How long an abandoned message stays hidden before the next attempt. | +| `EmptyQueueBackoffMin` / `Max` | 1 s / 30 s | Polling interval on an empty queue. | +| `ErrorBackoffMax` | 1 minute | Longest wait after a failed receive. | +| `ShutdownDrainTimeout` | 30 s | How long shutdown waits for running handlers. | + +## Receive in your own loop + +If you want the loop, inject `IQueueReceiver` and own it. You get every message's identity and settle it yourself; backoff, renewal, retries and shutdown are yours to handle, and a failed receive throws so your loop can react. - ```csharp - public class MySubscriber : BackgroundService +```csharp +public class OrderPoller(IQueueReceiver receiver, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - private readonly AzureStorageQueueClient _queueClient; - private readonly IMyMessageHandler _myMessageHandler; // your own handler - private readonly ILogger _logger; - - public MySubscriber(IQueueClientFactory queueClientFactory, IMyMessageHandler myMessageHandler, ILogger logger) - { - // get the default queue client - _queueClient = queueClientFactory.GetQueueClient(); - _myMessageHandler = myMessageHandler; - _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + while (!stoppingToken.IsCancellationRequested) { - while (!stoppingToken.IsCancellationRequested) + try { - try - { - await _queueClient.ReceiveMessagesAsync( - (message, metadata) => _myMessageHandler.HandleAsync(message), - (exception, metadata) => _myMessageHandler.HandleExceptionAsync(exception), - cancellationToken: stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + foreach (var message in await receiver.ReceiveAsync(maxMessages: 10, cancellationToken: stoppingToken)) { - break; + if (message.Error is not null) + { + await message.DeadLetterAsync(message.Error.Message, stoppingToken); + continue; + } + + await Fulfil(message.Body!); + await message.CompleteAsync(); } - catch (Exception ex) - { - _logger.LogError(ex, "Unexpected error while polling the queue."); - } - - await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception ex) { logger.LogError(ex, "Polling failed."); } + + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); } } - ``` +} +``` -### Handling multiple messages +`QueueMessage` carries `MessageId`, `PopReceipt`, `DequeueCount`, `InsertedOn`, `ExpiresOn`, `NextVisibleOn`, the `RawBody`, the typed `Body`, and `Error` when the body could not be decoded or deserialized. `CompleteAsync` deletes it; `AbandonAsync(delay)` makes it visible again now or later; `RenewLockAsync(timeout)` extends the lease; `DeadLetterAsync(reason)` moves it to the poison queue; `Settlement` records which of those happened. `PeekAsync` returns `PeekedQueueMessage`, which has no pop receipt and so nothing to settle. -The library allows you to pull multiple messages by specifying the `numMessages` count in the `ReceiveMessagesAsync()` method (the service allows at most 32). These are sent to the handler one at a time but pulled from the queue as a batch, and every message in the batch is hidden from other consumers for the visibility timeout from the moment the batch is received. Set `VisibilityTimeout` so that it covers the whole batch, otherwise the last messages in the batch may become visible again before you reach them. +## Testing your code -```csharp -await _queueClient.ReceiveMessagesAsync(HandleMessage, HandleException, numMessages: 10, cancellationToken: stoppingToken); -``` +Depend on `IQueueSender` and `IQueueReceiver` and fake them. A received message can be built in a test: `QueueMessage` has a public constructor that takes a `QueueMessageInfo`, the body and an `IQueueMessageOperations` — stub the operations to assert that your code completed or abandoned the message. -## OpenTelemetry Support +## Telemetry -The library supports OpenTelemetry logs, traces, and metrics and can be configured using the standard OTEL library as follows: +Traces and metrics use OpenTelemetry's standard .NET APIs: ```csharp builder.Services.AddOpenTelemetry() - .WithTracing(t => t.AddAzureStorageQueueTracing()) - .WithMetrics(m => m.AddAzureStorageQueueMetrics()); + .WithTracing(t => t.AddAzureStorageQueueTracing()) + .WithMetrics(m => m.AddAzureStorageQueueMetrics()); ``` -The library inclues two flags to control traces for sending messages and the creation of an Activity (span) for each query to retrieve a message. These two activities are: -- Queue.Read -- Queue.Send - ->NOTE: To observe the above spans, you need to configure the library to create them. Be cautious on enabling the `Queue.Read` span though as it creates a lot of data in your telemetry system; particularly if you read with a high frequency (i.e. every 1 second, etc.). - -```csharp -builder.Services.ConfigureQueueServiceTelemetry(t => -{ - t.CreateNewActivityOnMessageRetrieval = true; // Queue.Read trace - t.CreateNewActivityOnMessageSend = true; // Queue.Send trace -}); -``` +The processor emits a `Queue.Process` span (kind: consumer) per message, tagged with the queue name, message id, dequeue count and outcome. `Queue.Read` and `Queue.Send` spans for individual receives and sends are off by default because they are noisy; turn them on with `ConfigureQueueServiceTelemetry`. -The following metrics have also been configured and read through tools like Azure Monitor or Grafana: -- `queue_messages_received_total` -- `queue_messages_sent_total` -- `queue_messages_processed_total` -- `queue_messages_failed_total` -- `queue_message_processing_duration_seconds` (tagged with `queue.process_success`) -- `queue_message_size_bytes` +Metrics, all tagged with `queue.name`: `queue_messages_received_total`, `queue_messages_sent_total`, `queue_messages_processed_total`, `queue_messages_failed_total`, `queue_message_processing_duration_seconds` (also tagged with `queue.outcome`), `queue_message_size_bytes`. -All metrics are tagged with `queue.name`, so clients for different queues can be told apart. +## Upgrading from 2.x -## Upgrading to 2.1 +**3.0 requires .NET 10.** A project on .NET 8 cannot install it; upgrade the runtime first, or stay on the 2.x line, which remains on .NET 8 and keeps receiving fixes. -No code changes are required, but note these behaviour changes: +3.0 is a clean break: the 2.x client (`AzureStorageQueueClient`, `IAzureStorageQueueClient`, `IMessageConverter`, `GetQueueClient()`) is gone, not obsolete. Since moving to 3.0 already means a runtime upgrade, carrying the old API alongside the new one bought little and cost real complexity. The mapping: -- `ReceiveMessagesAsync` no longer throws when the queue read fails, when your exception handler throws, or when a message cannot be deleted. All of these now go to your exception handler (with `null` metadata when the read itself failed) and are logged. If you relied on catching a failed read in your own loop, that catch block will no longer fire. -- A queue configured with `CreateIfNotExists` is created on the first send, receive or peek instead of when the client is resolved from dependency injection, so startup no longer makes a blocking network call. -- Client configuration delegates run when the client is first requested rather than when `AddAzureStorageQueueClient` is called, and registering the same client name twice now adds to its configuration instead of ignoring the second registration. -- Custom `JsonSerializerOptions` are now applied when sending messages as well as when receiving them. If you configured a naming policy, messages you send will change shape accordingly. -- `Azure.Identity` is no longer a dependency of this package. Add it to your own project if you use `DefaultAzureCredential`. -- The package now depends on `Azure.Storage.Queues` 12.28 and `Microsoft.Extensions.*` 10.0. The 10.0 packages still target .NET 8, so no framework change is needed, but any `Microsoft.Extensions.*` packages you reference directly will be raised to 10.0 as well. This floor comes from the Azure SDK itself (`Azure.Core` 1.60 requires it), so it applies to any project on the current Azure SDK. +| 2.x | 3.0 | +|---|---| +| `IQueueClientFactory.GetQueueClient()` → `AzureStorageQueueClient` | `GetQueue()` → `IStorageQueue`, or inject `IStorageQueue` / `IQueueSender` / `IQueueReceiver` | +| `ReceiveMessagesAsync(handle, handleException, ...)` | `AddProcessor()`, or `ReceiveAsync` in your own loop | +| `SendMessageAsync(message, visibility, ttl)` | `SendAsync(message, new SendOptions(visibility, ttl))` | +| `PeekMessagesAsync(n)` → `IEnumerable` | `PeekAsync(n)` → `PeekedQueueMessage` with identity and `Error` | +| `IMessageConverter` | `IQueueMessageSerializer` | +| `QueueClientSettings.SerializerOptions` | `.UseJsonSerializer(options)` on the client builder | +| Base64 sniffing on receive | `ClientOptions.MessageEncoding = QueueMessageEncoding.Base64` | +| `x.AddClient("a", ...).AddClient("b", ...)` | Two statements: `AddClient` now returns a builder for that one client | +| `ApplicationException` on configuration errors | `InvalidOperationException` | + +Also removed: the synchronous `PeekMessages`, `MetricExtensions.ProcessWithMetricsAsync`, `QueueClientSettings.SectionName`, `TagListExtensions`, and the unused `queue_depth` metric. `LoggerMessageExtensions` is now internal: its methods were the library's own log messages, not an API. + +A queue name longer than 56 characters now needs `PoisonQueueName` set, even on a client that only sends, because the default `-poison` would exceed Azure's 63-character limit. The error message says so. ## License diff --git a/docs/v3-design.md b/docs/v3-design.md new file mode 100644 index 0000000..13c80a3 --- /dev/null +++ b/docs/v3-design.md @@ -0,0 +1,276 @@ +# AzureStorage.QueueService 3.0 — design + +Status: **implemented on the `v3` branch** (2026-10-15); this document is the record of the decisions. Tracks issue #39. Where the implementation diverged from the first draft, the text below says so. + +3.0 changes how the package is consumed. 2.x stays on the `2.x` branch for existing customers and keeps receiving fixes; `master` is 3.0. + +**Target framework.** 3.0 targets `net10.0` only; 2.x stays on `net8.0`. Decided 2026-10-15: .NET 8 leaves support in November 2026, a major version is the right moment to raise the floor, and the 2.x line exists for anyone who cannot move yet. The library gains nothing technically from .NET 10 — this is about support status — so the cost of a clean split is low and the cost of multi-targeting (two builds to keep green for no feature gain) is not worth paying. + +## Goals + +1. Customers never write a polling loop. The library owns it, including everything people get wrong: backoff, visibility renewal, poison messages, cancellation, shutdown. +2. Sending and receiving are separate things with separate interfaces, and receiving gives the caller the message's identity (id, dequeue count, pop receipt) and explicit control (complete, abandon, dead-letter, renew). +3. Each named client is a handle that extensions can attach to, the way `IHttpClientBuilder` works. +4. ~~Everything 2.x customers call keeps compiling for the whole of 3.x, marked `[Obsolete]`.~~ Dropped on 2026-10-15; 3.0 is a clean break and 2.x stays maintained. See "Compatibility layer". + +Not goals for 3.0: an in-memory test double (3.1), `Microsoft.Extensions.Azure` integration (prototype first; see "Open decisions"), OpenTelemetry semantic-convention rename (3.1). + +## Registration + +```csharp +services.AddAzureStorageQueueClient(queues => +{ + queues.AddClient("orders", configuration.GetSection("Queues:Orders")) + .UseJsonSerializer(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }) + .AddProcessor(o => + { + o.MaxConcurrentMessages = 4; + o.MaxDequeueCount = 5; + }); + + queues.AddDefaultClient(o => + { + o.ConnectionString = configuration["Storage:ConnectionString"]; + o.QueueName = "notifications"; + }); +}); +``` + +`AddClient` and `AddDefaultClient` now **return a per-client builder** instead of the parent. This is the one break that cannot be hidden (source-compatible for single-line `x.AddClient(...)` calls, binary-breaking otherwise). + +```csharp +public interface IQueueClientBuilder +{ + /// The registered name; Options.DefaultName ("") for the default client. + string Name { get; } + IServiceCollection Services { get; } +} + +// Everything else is an extension method, exactly like IHttpClientBuilder, so other packages extend it the same way. +public static class QueueClientBuilderExtensions +{ + public static IQueueClientBuilder Configure(this IQueueClientBuilder b, Action configure); + public static IQueueClientBuilder UseSerializer(this IQueueClientBuilder b, IQueueMessageSerializer serializer); + public static IQueueClientBuilder UseJsonSerializer(this IQueueClientBuilder b, JsonSerializerOptions options); + public static IQueueClientBuilder AddProcessor(this IQueueClientBuilder b, Action? configure = null) + where THandler : class, IQueueMessageHandler; +} +``` + +(The first draft put these on the interface; the implementation moved them to extension methods so the interface is the minimal `IHttpClientBuilder` shape.) + +`QueueClientSettings` keeps `ConnectionString`, `QueueName`, `EndpointUri`, `TokenCredential`, `CreateIfNotExists`, `VisibilityTimeout`, `ClientOptions`, and gains `Serializer` (set by `UseSerializer` / `UseJsonSerializer`). `SerializerOptions` is obsolete and forwarded. `SectionName` is removed (it never matched the README). + +Configuration errors throw `InvalidOperationException` (was `ApplicationException`), naming the client and what is missing. `queues.ValidateOnStart()` on the parent builder (inside the configure delegate) opts into fail-fast at host start; `AddAzureStorageQueueClient` keeps returning `IServiceCollection` so chained calls still compile. + +The current internal `IQueueClientBuilder` (which constructs SDK clients from settings) is renamed `ISdkQueueClientFactory` to free the name for the public per-client builder. + +## Consumption + +Two ways to consume, from most to least opinionated. Neither is mandatory. + +1. **`AddProcessor()`** — recommended. The library owns the loop: backoff, lease renewal, poison queue, cancellation, drain. The customer writes a handler class. +2. **Your own `BackgroundService` over `IQueueReceiver`** — for customers who want the loop. `ReceiveAsync` returns messages with their identity and explicit `Complete` / `Abandon` / `DeadLetter` / `RenewLock`; the caller owns backoff, renewal and error handling, and transport failures throw so the caller's loop can react. + +(The first draft had a third path, the 2.x client kept as an obsolete façade; it was dropped — see "Compatibility layer".) + +Three interfaces, all registered per client (keyed by name) and for the default client (unkeyed): + +```csharp +/// Common base, so IStorageQueue inherits one QueueName rather than an ambiguous pair. +public interface INamedQueue { string QueueName { get; } } + +public interface IQueueSender : INamedQueue +{ + ValueTask SendAsync(T message, SendOptions? options = null, CancellationToken cancellationToken = default); +} + +public interface IQueueReceiver : INamedQueue +{ + /// visibilityTimeout defaults to QueueClientSettings.VisibilityTimeout, then the service default (30 s). maxMessages is 1..32. + ValueTask>> ReceiveAsync(int maxMessages = 1, TimeSpan? visibilityTimeout = null, CancellationToken cancellationToken = default); + /// Peeked messages have no pop receipt, so they get a type with no operations on it. + ValueTask>> PeekAsync(int maxMessages = 1, CancellationToken cancellationToken = default); +} + +/// Sender + receiver + admin for one queue. What IQueueClientFactory hands out. +public interface IStorageQueue : IQueueSender, IQueueReceiver +{ + ValueTask CreateIfNotExistsAsync(IDictionary? metadata = null, CancellationToken cancellationToken = default); + ValueTask ClearAsync(CancellationToken cancellationToken = default); + ValueTask GetPropertiesAsync(CancellationToken cancellationToken = default); +} + +public interface IQueueClientFactory +{ + IStorageQueue GetQueue(string clientName); + IStorageQueue GetQueue(); +} + +public sealed record SendOptions(TimeSpan? VisibilityTimeout = null, TimeSpan? TimeToLive = null); +``` + +Injection: `IStorageQueue` / `IQueueSender` / `IQueueReceiver` for the default client; `[FromKeyedServices("orders")]` for named ones. A class that only sends depends on `IQueueSender` and is trivially mockable. + +### The message + +```csharp +public sealed class PeekedQueueMessage +{ + public string MessageId { get; } + public long DequeueCount { get; } + public DateTimeOffset? InsertedOn { get; } + public DateTimeOffset? ExpiresOn { get; } + public BinaryData RawBody { get; } + public T? Body { get; } + public DeserializationException? Error { get; } +} + +public sealed class QueueMessage +{ + /// Public so a test that stubs IQueueReceiver can build messages; the library passes its real operations. + public QueueMessage(QueueMessageInfo info, BinaryData rawBody, T? body, DeserializationException? error, IQueueMessageOperations operations); + + public string MessageId { get; } + public string PopReceipt { get; } // changes after Renew/Abandon + public long DequeueCount { get; } + public DateTimeOffset? InsertedOn { get; } + public DateTimeOffset? ExpiresOn { get; } + public DateTimeOffset? NextVisibleOn { get; } + public BinaryData RawBody { get; } + + /// Null when deserialization failed (see Error) or when the body was JSON null. + public T? Body { get; } + /// Set when the body could not be deserialized. The message is still returned so the caller can dead-letter it. + public DeserializationException? Error { get; } + /// True once Complete, Abandon or DeadLetter has succeeded. Further operations throw InvalidOperationException. + public bool IsSettled { get; } + + public ValueTask CompleteAsync(CancellationToken cancellationToken = default); // delete + public ValueTask AbandonAsync(TimeSpan? visibilityTimeout = null, CancellationToken cancellationToken = default); // make visible again (now, or after a delay) + public ValueTask RenewLockAsync(TimeSpan visibilityTimeout, CancellationToken cancellationToken = default); // extend the lease; updates PopReceipt + public ValueTask DeadLetterAsync(string? reason = null, CancellationToken cancellationToken = default); // copy to the poison queue, then delete +} + +/// What the library implements over the SDK client; what a test stubs. +public interface IQueueMessageOperations +{ + ValueTask DeleteAsync(string messageId, string popReceipt, CancellationToken cancellationToken); + ValueTask UpdateVisibilityAsync(string messageId, string popReceipt, TimeSpan visibilityTimeout, CancellationToken cancellationToken); + ValueTask DeadLetterAsync(string messageId, string popReceipt, long dequeueCount, BinaryData rawBody, string? reason, CancellationToken cancellationToken); +} +``` + +Operations on one `QueueMessage` are serialized internally (a renewal timer and a handler calling `CompleteAsync` at the same moment must not race on `PopReceipt`), and every operation uses the latest receipt. After `IsSettled`, further operations throw. + +`ReceiveAsync` never throws for a bad body — it returns the message with `Error` set — so a poison message can be handled instead of pinning the consumer. It does throw for transport failures (`RequestFailedException`) and caller cancellation; the receiver is the low-level API and its caller owns the loop. The processor is where "never throws" lives. + +Dead-lettering sends the **raw body unchanged** to `-poison` (creating it if needed), then deletes the original. The reason, message id, dequeue count and insertion time go to the log, not into the message: a poison message can then be moved straight back to the main queue to replay it, and the behaviour matches Azure Functions. Because copy-then-delete is two calls, a lease that expires between them can produce a duplicate in the poison queue; that is the at-least-once trade and is documented. There are no Azure Storage Queue message properties to carry a reason in. + +## The processor + +```csharp +public interface IQueueMessageHandler +{ + ValueTask HandleAsync(QueueMessage message, CancellationToken cancellationToken); +} + +public sealed class QueueProcessorOptions +{ + public int MaxConcurrentMessages { get; set; } = 1; // workers; each receives one message and delivers it (MaxMessagesPerReceive was dropped with the worker model) + public TimeSpan VisibilityTimeout { get; set; } = TimeSpan.FromMinutes(1); // the processor's own; QueueClientSettings.VisibilityTimeout applies to direct ReceiveAsync calls only + public bool RenewVisibility { get; set; } = true; // renew at half the timeout while the handler runs + public bool AutoComplete { get; set; } = true; // complete on return, abandon on throw + public int MaxDequeueCount { get; set; } = 5; // then dead-letter + public string? PoisonQueueName { get; set; } // default "-poison" + public bool DeadLetterOnDeserializationFailure { get; set; } = true; + public Func RetryDelay { get; set; } // delay before a failed message is visible again; default 5s * 2^(n-1), capped at 5 min + public TimeSpan EmptyQueueBackoffMin { get; set; } = TimeSpan.FromSeconds(1); + public TimeSpan EmptyQueueBackoffMax { get; set; } = TimeSpan.FromSeconds(30); + public TimeSpan ErrorBackoffMax { get; set; } = TimeSpan.FromMinutes(1); + public TimeSpan ShutdownDrainTimeout { get; set; } = TimeSpan.FromSeconds(30); +} +``` + +Behaviour, per processor registration (one hosted service each): + +- **Scope per message.** `THandler` is resolved from a new `IServiceScope` for every message, so scoped services (`DbContext`, request-scoped loggers) work the way they do in a controller. `AddProcessor` registers and resolves the concrete `THandler` type, not `IQueueMessageHandler`, so two queues carrying the same `TMessage` can have different handlers. +- **Handler contract.** With `DeadLetterOnDeserializationFailure = false` the handler can receive a message whose `Body` is null and `Error` is set; otherwise `Body` is null only when the message was JSON `null`. +- **Poison first.** Before the handler sees a message: if `DequeueCount > MaxDequeueCount`, or `Error` is set and `DeadLetterOnDeserializationFailure`, the message is dead-lettered and the handler is not called. +- **Lease renewal.** While the handler runs, the lease is renewed at half `VisibilityTimeout`. A handler that takes ten minutes no longer causes duplicate processing. The renewal timer is stopped before the message is settled, so a renew and a complete never race. +- **Completion.** With `AutoComplete`, a handler that returns completes the message; one that throws abandons it with `RetryDelay(DequeueCount)`. A handler may settle the message itself (`IsSettled`), and the processor then does nothing further for it. Completing after a successful handler is **not** tied to the shutdown token (the 2.1 lesson: cancelling it only redelivers work that is already done). +- **Nothing escapes.** Transport errors on receive are logged and backed off (exponential to `ErrorBackoffMax`); a missing queue is logged with "set CreateIfNotExists" and backed off the same way; handler exceptions are logged with message id and dequeue count; an exception from dead-lettering (for example, no permission to create the poison queue) falls back to abandon with the maximum retry delay and an error-level log, so nothing is lost silently. The host never stops because of this library. +- **Empty queue.** Polling backs off exponentially from `EmptyQueueBackoffMin` to `EmptyQueueBackoffMax`, resetting on the first non-empty receive. +- **Concurrency.** Up to `MaxConcurrentMessages` handlers in flight, each with its own scope and renewal timer. +- **Lifecycle.** Implements `IHostedLifecycleService`: `StartAsync` validates options and resolves the queue (a misconfigured client fails startup on purpose); polling starts in `StartedAsync`, after the application is up, so a slow startup does not hold leases. On shutdown it stops receiving, waits up to `ShutdownDrainTimeout` for in-flight handlers to finish on their own, and only then cancels their token (drain, then cancel — the first draft had these the other way round). A cancelled handler's message is abandoned with no delay so another instance can take it. Messages still unsettled become visible again when their lease lapses. +- **Telemetry.** A `Consumer` span per message, the existing counters and histogram tagged with queue name and processor outcome (`completed`, `abandoned`, `dead_lettered`). + +## Serialization + +```csharp +public interface IQueueMessageSerializer +{ + BinaryData Serialize(T value); + T? Deserialize(BinaryData data); +} +``` + +Per client, default `System.Text.Json`. Base64 is the SDK's job: set `ClientOptions.MessageEncoding = QueueMessageEncoding.Base64` for queues shared with Azure Functions or older SDKs. The Base64 sniffing heuristic and `IMessageConverter` are gone. A message the SDK cannot decode is returned with `Error` set (see F2 below) rather than failing the batch. + +## Compatibility layer — dropped + +The first draft kept the 2.x client as an `[Obsolete]` façade over `IStorageQueue` for the whole of 3.x. **Decided 2026-10-15 to drop it**, after the independent review (issue #39) and the decision to target .NET 10: anyone adopting 3.0 must upgrade their runtime anyway, so compile-time compatibility bought little, while the façade cost a second cache, a second serializer path threaded through the transport, Base64 sniffing kept alive, and ~250 lines of code and tests. Customers who cannot migrate stay on the maintained `2.x` branch. The README carries the 2.x → 3.0 mapping. + +Removed in 3.0: `AzureStorageQueueClient`, `IAzureStorageQueueClient`, `IMessageConverter`, `GetQueueClient(...)`, `QueueClientSettings.SerializerOptions`, Base64 sniffing, the synchronous `PeekMessages`, the three old `LoggerMessageExtensions` methods, `MetricExtensions.ProcessWithMetricsAsync`, `QueueClientSettings.SectionName`, `ApplicationException` as a thrown type. + +## Self-review (2026-10-15) + +Problems found reading the first draft adversarially, all folded in above: peek returned a type with operations that cannot work (now `PeekedQueueMessage`); `QueueMessage` could not be built by a test (public constructor + `IQueueMessageOperations`); renewal and completion could race on `PopReceipt` (serialized operations, `IsSettled`, timer stopped before settling); the processor held leases on messages it was not working on (`MaxMessagesPerReceive` defaults to `MaxConcurrentMessages`); two visibility-timeout settings with no precedence (stated); the dead-letter envelope broke replay (raw body unchanged); handler registration by interface collided across queues (concrete type); `ValidateOnStart` had nowhere to live without a breaking return type (parent builder); the public builder name clashed with an internal type (rename). + +## Independent review (2026-10-15) and what changed + +An independent review on issue #39 found eight correctness problems in the first implementation, all confirmed and fixed, with a regression test each: + +- **F1** The host's shutdown deadline could cancel `StopAsync` before handlers were cancelled, leaving handlers and renewals running. Cleanup is now in a `finally`: however `StopAsync` exits, handler and renewal tokens are cancelled. +- **F2** With `MessageEncoding = Base64`, one undecodable message made the SDK throw for the whole receive. The SDK's `MessageDecodingFailed` event is now captured (via an `AsyncLocal` collector, since the event fires inside the receive call) and such messages come back with `Error` set, identity intact, so they can be dead-lettered. Verified against Azurite. +- **F3** Finishing a handler cancelled a renewal request that might already have been accepted, so completion could use a stale receipt. Only the wait between renewals is cancellable now; an in-flight renewal is always awaited before settling. +- **F4** A handler with a long synchronous prefix delayed dispatch of the rest of a batch. Resolved structurally: the processor is now worker-per-slot, each worker receiving one message and delivering it inline. `MaxMessagesPerReceive` is gone. (The second review found the same gap one step earlier — deserialization ran before renewal was armed — closed as R2 below.) +- **F5** Handler construction, scope disposal and a throwing `RetryDelay` escaped the exception boundary and were discarded unobserved. One delivery routine now owns the whole lifecycle inside a single boundary; a throwing `RetryDelay` falls back to five minutes. +- **F6** `-poison` exceeded Azure's 63-character limit for long queue names, and a trailing slash on the endpoint URI produced `/orders/orders-poison`. `PoisonQueueName` is now a setting, validated at startup and on first use; sibling URIs are built by replacing the last path segment. +- **F7** `GetPropertiesAsync` skipped `CreateIfNotExists`. Fixed. +- **F8** (inherited from 2.x) `TagList` is a struct, so the callback-based activity helper mutated a copy and every `Queue.Read` / `Queue.Send` span was emitted with no tags. Tags are now passed populated; a test with a real `ActivityListener` checks them. + +A second review pass found six more, all confirmed and fixed with a regression test each: + +- **R1** Each delivery's linked cancellation source was cancelled but never disposed, so the processor-wide renewal token retained one registration per message ever processed. `LeaseRenewal` now has one end of life (`DisposeAsync`) and the processor disposes its own sources. The test proves it with a `WeakReference` after GC, with an undisposed control case. +- **R2** The worker called the typed receive, which deserializes, and only then armed renewal, so a slow serializer could let the lease lapse. The processor now receives undeserialized through an internal `IOwnedReceiver`, arms renewal, then deserializes inside the delivery boundary. The public `ReceiveAsync` is unchanged. +- **R3** At the drain deadline handlers were cancelled but renewal only stopped in the `finally`, so a handler that ignored its token kept its lease alive indefinitely. Renewal now stops at the same moment handlers are cancelled (an in-flight renewal still completes), and an uncooperative handler is waited for one more `ShutdownDrainTimeout`, then abandoned to the lease. +- **R4** Durations beyond what the timer APIs accept (~49 days) passed validation and faulted a worker unobserved. Validated at startup, including the 7-day service ceiling on `VisibilityTimeout`; a worker that fails unexpectedly is logged at Critical and restarted. +- **R5** `PoisonQueueName` equal to the source queue made dead-lettering re-enqueue the message with a fresh dequeue count, for ever. Rejected for both connection styles. +- **R6** `ValidateOnStart` checked only an explicit `PoisonQueueName`, not the derived default. Derivation and validation now live in one place (`QueueClientSettings.Validate`) used by both startup validation and construction. + +A third pass found four, three of them regressions from the R-fixes — the lesson being that validation has a scope, and reusing it across scopes is how it goes wrong: + +- **S1** Source-queue validation was reused when building the *poison* client, so it demanded a poison queue for the poison queue and failed for source names of 50–56 characters. Split into `ValidateConnection()` (what any SDK client needs; used for every client) and `Validate()` (connection plus source policy; used for sources and by `ValidateOnStart`). Regression-tested with the real SDK factory, since the mock had hidden it, and end-to-end on Azurite. +- **S2** The effective queue name preferred `QueueName` even under endpoint authentication, where the SDK ignores it, so a stale alias could hide a self-poison configuration. The effective name now follows construction precedence exactly, and a `QueueName` that disagrees with the endpoint's queue is rejected. +- **S3** The processor's `VisibilityTimeout` lower bound was checked against zero while the message said one second; 500 ms passed and became `visibilitytimeout=0`, a permanently failing receive. Now 1 second to 7 days, on both the processor options and the client setting. +- **S4** Workers started in the processor's `StartedAsync`, but a *later* service's `StartedAsync` can still fail, after which the host disposes everything without `StopAsync` — and `Dispose` disposed the token sources without cancelling them, so already-running workers kept polling. Workers now start on `IHostApplicationLifetime.ApplicationStarted` (never fired by a failed startup), and `Dispose` cancels before it disposes. Tested through a real Generic Host. + +Simplifications taken from the same review: `MessageSettlement` enum instead of a bare `IsSettled`, so a handler that abandons or dead-letters is not counted as a success; our own `VisibilityReceipt` record instead of the SDK's `UpdateReceipt` in `IQueueMessageOperations`; options read once through `IOptionsFactory` (a snapshot) instead of `IOptionsMonitor`; the façade dropped (above). Not taken: making keyed DI own queue lifetimes (the factory stays the single source of truth and works without a container), and a disposition-returning handler API (a different design from the Service Bus-style one agreed; revisit after real use). + +## Open decisions + +1. **`Microsoft.Extensions.Azure` underneath.** Gains credentials from configuration (`"Credential": "managedidentity"`), which `TokenCredential` cannot do today. Costs a dependency and their config schema. Decision: prototype on the `v3` branch before the processor work starts; adopt if `AddClient(name, IConfiguration)` can bind both schemas without surprising anyone. +2. **Abandon delay vs. leaving the lease to expire.** Proposed: abandon with an explicit growing delay, so a failing message does not hot-loop and does not wait a full visibility timeout either. Alternative: do nothing and let the lease expire (simpler, but ties retry timing to the lease length). +3. **Package split.** One package, or `AzureStorage.QueueService.Abstractions` (interfaces + `QueueMessage`) so handler libraries do not take the SDK dependency. Proposed: one package for 3.0; split only if asked. + +## Work breakdown + +Planned as four pull requests; delivered as one (#42) at the owner's request. The slices, for orientation in the diff: + +1. `IQueueMessageSerializer`, `QueueMessage`, `IQueueSender` / `IQueueReceiver` / `IStorageQueue`, factory returning `IStorageQueue`, keyed registrations; 2.x façade rebuilt on top. All existing tests pass unchanged through the façade. +2. Per-client `IQueueClientBuilder`; `AddClient` / `AddDefaultClient` return it; `ValidateOnStart`; `InvalidOperationException`. +3. The processor: options, handler, hosted service, poison queue, renewal, backoff, drain. Integration tests against Azurite with fake time for the backoff and renewal timers. +4. Removals, README rewrite, migration guide, `3.0.0-preview.1` tag. + +Previews publish from `v3.0.0-preview.N` tags with the existing `nuget.yml`; nuget.org marks them pre-release automatically. diff --git a/src/AzureStorage.QueueService/AzureStorage.QueueService.csproj b/src/AzureStorage.QueueService/AzureStorage.QueueService.csproj index 6761dd2..9544592 100644 --- a/src/AzureStorage.QueueService/AzureStorage.QueueService.csproj +++ b/src/AzureStorage.QueueService/AzureStorage.QueueService.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable $(MSBuildProjectName) @@ -10,7 +10,7 @@ Unifinity Software Corporation True MIT - A library used to read from an Azure Storage Queue. Use this package in combination with code samples for Azure Communication Services projects where Event Grid subscriptions from a storage queue are used. + Typed sending, receiving and processing for Azure Storage queues, registered like IHttpClientFactory clients. Includes a hosted processor with lease renewal, retries, a poison queue and graceful shutdown. Unifinity Software Corporation Azure, Azure Storage, Azure Storage Queue, Storage Queue, Queue Service, Jason Shave, Unifinity Software Corporation https://github.com/jasonshave/AzureStorage.QueueService @@ -27,10 +27,10 @@ + - diff --git a/src/AzureStorage.QueueService/AzureStorageQueueClient.cs b/src/AzureStorage.QueueService/AzureStorageQueueClient.cs deleted file mode 100644 index 5f160fb..0000000 --- a/src/AzureStorage.QueueService/AzureStorageQueueClient.cs +++ /dev/null @@ -1,307 +0,0 @@ -using Azure.Storage.Queues; -using Azure.Storage.Queues.Models; -using AzureStorage.QueueService.Telemetry; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using System.Diagnostics; - -namespace AzureStorage.QueueService; - -public class AzureStorageQueueClient : IAzureStorageQueueClient -{ - // The service default when no visibility timeout is supplied on receive. - private static readonly TimeSpan DefaultVisibilityTimeout = TimeSpan.FromSeconds(30); - - private readonly IMessageConverter _messageConverter; - private readonly QueueClient _queueClient; - private readonly ILogger _logger; - private readonly QueueServiceTelemetrySettings _telemetrySettings; - private readonly TimeSpan? _visibilityTimeout; - private readonly bool _createIfNotExists; - private readonly SemaphoreSlim _createLock = new(1, 1); - private volatile bool _queueEnsured; - - internal AzureStorageQueueClient( - IMessageConverter messageConverter, - QueueClient queueClient, - ILoggerFactory loggerFactory, - IOptions telemetrySettingsOptions, - TimeSpan? visibilityTimeout = null, - bool createIfNotExists = false) - { - _messageConverter = messageConverter; - _queueClient = queueClient; - _logger = loggerFactory.CreateLogger(); - _telemetrySettings = telemetrySettingsOptions.Value; - _visibilityTimeout = visibilityTimeout; - _createIfNotExists = createIfNotExists; - } - - public string QueueName => _queueClient.Name; - - public async ValueTask CreateQueueIfNotExistsAsync(IDictionary? metadata = null, CancellationToken cancellationToken = default) - { - await _queueClient.CreateIfNotExistsAsync(metadata, cancellationToken); - _queueEnsured = true; - } - - public async ValueTask ClearMessagesAsync(CancellationToken cancellationToken = default) - { - await EnsureQueueExistsAsync(cancellationToken); - await _queueClient.ClearMessagesAsync(cancellationToken); - } - - public async ValueTask> PeekMessagesAsync(int numMessages, CancellationToken cancellationToken = default) - { - await EnsureQueueExistsAsync(cancellationToken); - return (await _queueClient.PeekMessagesAsync(numMessages, cancellationToken)).Value.Convert(_messageConverter); - } - - public IEnumerable PeekMessages(int numMessages, CancellationToken cancellationToken = default) - { - EnsureQueueExists(cancellationToken); - return _queueClient.PeekMessages(numMessages, cancellationToken).Value.Convert(_messageConverter); - } - - /// - /// Receives up to messages, deserializes each one and passes it to - /// . A message is deleted from the queue only after its handler completes; - /// if the handler throws, the message is left on the queue and becomes visible again once its visibility - /// timeout expires. - /// - /// - /// This method does not throw for failures in the queue read, the message conversion, the handler, the delete, - /// or itself. All of them are logged and passed to - /// (with null metadata when the read itself failed), and processing - /// continues with the next message in the batch. The only exception that escapes is - /// for the caller's own . - /// - /// The type to deserialize each message into. - /// Called once per message with the deserialized payload and the queue's metadata. - /// Called when receiving, deserializing, handling or deleting a message fails. - /// How many messages to receive in one call (the service allows at most 32). - /// Stops the receive and any further message processing. Messages still in the batch become visible again after their visibility timeout. - public async ValueTask ReceiveMessagesAsync( - Func?, ValueTask> handleMessage, - Func?, ValueTask> handleException, - int numMessages = 1, - CancellationToken cancellationToken = default) - where TMessage : class - { - ArgumentNullException.ThrowIfNull(handleMessage); - ArgumentNullException.ThrowIfNull(handleException); - - using Activity? activity = _telemetrySettings.CreateNewActivityOnMessageRetrieval - ? QueueServiceDiagnostics.Activities.StartReadingQueueActivity(tags => - { - tags.Add(QueueServiceDiagnostics.Names.QueueName, QueueName); - tags.Add(QueueServiceDiagnostics.Names.QueueMessageCount, numMessages); - }) - : null; - - QueueMessage[] receivedMessages; - try - { - await EnsureQueueExistsAsync(cancellationToken); - receivedMessages = await _queueClient.ReceiveMessagesAsync(numMessages, _visibilityTimeout, cancellationToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - // A failed read (network, auth, missing queue, bad numMessages) used to escape straight into the - // caller's polling loop and take the host down. It is now reported like any other failure. - _logger.LogReceiveFailed(QueueName, ex); - activity?.AddException(ex); - await InvokeExceptionHandlerAsync(handleException, ex, metadata: null, messageId: null); - return; - } - - if (receivedMessages.Length == 0) return; - - _logger.LogMessagesReceived(QueueName, receivedMessages.Length); - activity?.AddEvent(new ActivityEvent(QueueServiceDiagnostics.Events.QueueMessageReceived)); - - var metadata = await TryGetMetadataAsync(cancellationToken); - - foreach (var queueMessage in receivedMessages) - { - cancellationToken.ThrowIfCancellationRequested(); - await ProcessMessageAsync(queueMessage, metadata, handleMessage, handleException, activity, cancellationToken); - } - } - - private async ValueTask ProcessMessageAsync( - QueueMessage queueMessage, - IDictionary? metadata, - Func?, ValueTask> handleMessage, - Func?, ValueTask> handleException, - Activity? activity, - CancellationToken cancellationToken) - where TMessage : class - { - var tags = new TagList - { - new(QueueServiceDiagnostics.Names.QueueName, QueueName), - }; - - QueueServiceDiagnostics.Metrics.MessagesReceived.Add(1, tags); - - var start = Stopwatch.GetTimestamp(); - var handled = false; - var success = false; - - try - { - var convertedMessage = _messageConverter.Convert(queueMessage.MessageText); - await handleMessage(convertedMessage, metadata); - handled = true; - - // The handler's work is done, so the delete is deliberately not tied to the caller's token: - // cancelling it during shutdown would only redeliver a message that was already processed. - await _queueClient.DeleteMessageAsync(queueMessage.MessageId, queueMessage.PopReceipt, CancellationToken.None); - - _logger.LogMessageProcessed(QueueName, queueMessage.MessageId); - activity?.AddEvent(new ActivityEvent(QueueServiceDiagnostics.Events.QueueMessageProcessed)); - QueueServiceDiagnostics.Metrics.MessagesProcessed.Add(1, tags); - success = true; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - if (handled) - _logger.LogDeleteFailed(QueueName, queueMessage.MessageId, _visibilityTimeout ?? DefaultVisibilityTimeout, ex); - else - _logger.LogHandlerFailed(QueueName, queueMessage.MessageId, queueMessage.DequeueCount, ex); - - QueueServiceDiagnostics.Metrics.MessageFailures.Add(1, tags); - activity?.AddException(ex); - await InvokeExceptionHandlerAsync(handleException, ex, metadata, queueMessage.MessageId); - } - finally - { - tags.Add(QueueServiceDiagnostics.Names.QueueProcessSuccess, success); - QueueServiceDiagnostics.Metrics.MessageProcessingDuration.Record(Stopwatch.GetElapsedTime(start).TotalSeconds, tags); - } - } - - private async ValueTask InvokeExceptionHandlerAsync( - Func?, ValueTask> handleException, - Exception exception, - IDictionary? metadata, - string? messageId) - { - try - { - await handleException(exception, metadata); - } - catch (Exception handlerException) - { - // The caller's exception handler is the last line of defence; if it throws there is nowhere left - // to report to but the log. Letting it escape would abandon the rest of the batch. - _logger.LogExceptionHandlerFailed(QueueName, messageId ?? "(none)", handlerException); - } - } - - private async ValueTask?> TryGetMetadataAsync(CancellationToken cancellationToken) - { - try - { - var response = await _queueClient.GetPropertiesAsync(cancellationToken); - return response?.Value?.Metadata; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - _logger.LogMetadataFailed(QueueName, ex); - return null; - } - } - - /// - /// Sends a message of a specified type and handles serialization of the message to the correct format. - /// - /// - /// - /// - /// - /// - /// - /// Serialization and send failures are logged and rethrown. - public async ValueTask SendMessageAsync(TMessage message, TimeSpan? visibilityTimeout = null, TimeSpan? timeToLive = null, CancellationToken cancellationToken = default) - { - using Activity? activity = _telemetrySettings.CreateNewActivityOnMessageSend - ? QueueServiceDiagnostics.Activities.StartSendingMessageActivity(tags => - { - tags.Add(QueueServiceDiagnostics.Names.QueueName, QueueName); - }) - : null; - - try - { - await EnsureQueueExistsAsync(cancellationToken); - - BinaryData binaryMessage = _messageConverter.Convert(message); - SendReceipt response = await _queueClient.SendMessageAsync(binaryMessage, visibilityTimeout, timeToLive, cancellationToken); - - var tags = new TagList - { - new(QueueServiceDiagnostics.Names.QueueName, QueueName), - }; - QueueServiceDiagnostics.Metrics.MessagesSent.Add(1, tags); - QueueServiceDiagnostics.Metrics.MessageSize.Record(binaryMessage.ToMemory().Length, tags); - - return response; - } - catch (Exception e) - { - _logger.LogSendFailed(QueueName, e); - activity?.AddException(e); - throw; - } - } - - // Queue creation used to be a blocking network call made while resolving the client from DI, typically inside - // a constructor during startup. It now happens on the first operation, once, and asynchronously. - private async ValueTask EnsureQueueExistsAsync(CancellationToken cancellationToken) - { - if (!_createIfNotExists || _queueEnsured) return; - - await _createLock.WaitAsync(cancellationToken); - try - { - if (_queueEnsured) return; - await _queueClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken); - _queueEnsured = true; - } - finally - { - _createLock.Release(); - } - } - - private void EnsureQueueExists(CancellationToken cancellationToken) - { - if (!_createIfNotExists || _queueEnsured) return; - - _createLock.Wait(cancellationToken); - try - { - if (_queueEnsured) return; - _queueClient.CreateIfNotExists(cancellationToken: cancellationToken); - _queueEnsured = true; - } - finally - { - _createLock.Release(); - } - } -} diff --git a/src/AzureStorage.QueueService/DecodingFailures.cs b/src/AzureStorage.QueueService/DecodingFailures.cs new file mode 100644 index 0000000..6d46a5a --- /dev/null +++ b/src/AzureStorage.QueueService/DecodingFailures.cs @@ -0,0 +1,46 @@ +using Azure.Storage.Queues; +using Azure.Storage.Queues.Models; + +namespace AzureStorage.QueueService; + +/// +/// Catches messages the Azure SDK could not Base64-decode, so they come back from a receive as messages with +/// set instead of failing the whole batch. +/// +/// +/// With MessageEncoding = Base64 the SDK decodes before the library sees anything. Without a +/// handler, one undecodable message throws for the entire +/// receive and every healthy message in the batch stays hidden until its lease lapses. The library's SDK client +/// captures an internal handler at construction, without leaving it on caller-owned options. That handler runs +/// inside the receive call, on the same async flow, which is why an +/// is enough to hand it back to the caller. +/// +internal static class DecodingFailures +{ + private static readonly AsyncLocal?> Current = new(); + + public static List Begin() + { + var failures = new List(); + Current.Value = failures; + return failures; + } + + public static void End() => Current.Value = null; + + internal static Task OnDecodingFailedAsync(QueueMessageDecodingFailedEventArgs args) + { + if (!TryCollect(args)) + throw new FormatException("The queue message could not be decoded using the configured Base64 encoding."); + + return Task.CompletedTask; + } + + // Only intercept failures belonging to a wrapped receive or peek. The SDK keeps its usual behavior otherwise. + internal static bool TryCollect(QueueMessageDecodingFailedEventArgs args) + { + if (Current.Value is not { } failures) return false; + failures.Add(args); + return true; + } +} diff --git a/src/AzureStorage.QueueService/IAzureStorageQueueClient.cs b/src/AzureStorage.QueueService/IAzureStorageQueueClient.cs deleted file mode 100644 index cab143b..0000000 --- a/src/AzureStorage.QueueService/IAzureStorageQueueClient.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Azure.Storage.Queues.Models; - -namespace AzureStorage.QueueService; - -/// -/// A client bound to one Azure Storage queue. is the only implementation; -/// depend on this interface when you want to substitute a fake in your own unit tests. -/// -public interface IAzureStorageQueueClient -{ - /// - /// The name of the queue this client is bound to. - /// - string QueueName { get; } - - ValueTask CreateQueueIfNotExistsAsync(IDictionary? metadata = null, CancellationToken cancellationToken = default); - - ValueTask ClearMessagesAsync(CancellationToken cancellationToken = default); - - ValueTask> PeekMessagesAsync(int numMessages, CancellationToken cancellationToken = default); - - IEnumerable PeekMessages(int numMessages, CancellationToken cancellationToken = default); - - /// - ValueTask ReceiveMessagesAsync( - Func?, ValueTask> handleMessage, - Func?, ValueTask> handleException, - int numMessages = 1, - CancellationToken cancellationToken = default) - where TMessage : class; - - /// - ValueTask SendMessageAsync(TMessage message, TimeSpan? visibilityTimeout = null, TimeSpan? timeToLive = null, CancellationToken cancellationToken = default); -} diff --git a/src/AzureStorage.QueueService/IMessageConverter.cs b/src/AzureStorage.QueueService/IMessageConverter.cs deleted file mode 100644 index 99750c0..0000000 --- a/src/AzureStorage.QueueService/IMessageConverter.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace AzureStorage.QueueService; - -public interface IMessageConverter -{ - TOutput? Convert(BinaryData input); - - TOutput? Convert(string input); - - BinaryData Convert(TInput input); -} \ No newline at end of file diff --git a/src/AzureStorage.QueueService/INamedQueue.cs b/src/AzureStorage.QueueService/INamedQueue.cs new file mode 100644 index 0000000..14a438d --- /dev/null +++ b/src/AzureStorage.QueueService/INamedQueue.cs @@ -0,0 +1,10 @@ +namespace AzureStorage.QueueService; + +/// +/// Something bound to one queue. The common base of and , +/// so inherits a single . +/// +public interface INamedQueue +{ + string QueueName { get; } +} diff --git a/src/AzureStorage.QueueService/IOwnedReceiver.cs b/src/AzureStorage.QueueService/IOwnedReceiver.cs new file mode 100644 index 0000000..63ccd7b --- /dev/null +++ b/src/AzureStorage.QueueService/IOwnedReceiver.cs @@ -0,0 +1,16 @@ +namespace AzureStorage.QueueService; + +/// +/// The processor's receive path. A message comes back with its identity and raw body but without deserialization, +/// so the processor can arm lease renewal first and deserialize inside the delivery boundary. The public +/// contract is unchanged; this is implemented by +/// and used when the processor's queue supports it. +/// +internal interface IOwnedReceiver +{ + /// Receives one message, or null when the queue is empty. is set only for a decoding failure. + ValueTask?> ReceiveUndeserializedAsync(TimeSpan visibilityTimeout, CancellationToken cancellationToken); + + /// Fills in or . Never throws. + void Deserialize(QueueMessage message); +} diff --git a/src/AzureStorage.QueueService/IQueueClientBuilder.cs b/src/AzureStorage.QueueService/IQueueClientBuilder.cs index 01cb4e5..8b42e1d 100644 --- a/src/AzureStorage.QueueService/IQueueClientBuilder.cs +++ b/src/AzureStorage.QueueService/IQueueClientBuilder.cs @@ -1,12 +1,29 @@ -using Azure.Storage.Queues; +using Microsoft.Extensions.DependencyInjection; namespace AzureStorage.QueueService; -internal interface IQueueClientBuilder +/// +/// A handle for one registered client, returned by AddClient and AddDefaultClient. Extension methods +/// hang off it (), the way they do off IHttpClientBuilder, so +/// features can be attached to one specific client — including by other packages. +/// +public interface IQueueClientBuilder { - /// - /// Constructs the SDK client for the given settings. names the client - /// (for example "Queue client 'orders'") so configuration errors can say which one is at fault. - /// - QueueClient CreateClient(string clientDescription, QueueClientSettings settings); + /// The registered name; for the default client. + string Name { get; } + + IServiceCollection Services { get; } +} + +internal sealed class QueueClientBuilder : IQueueClientBuilder +{ + public QueueClientBuilder(string name, IServiceCollection services) + { + Name = name; + Services = services; + } + + public string Name { get; } + + public IServiceCollection Services { get; } } diff --git a/src/AzureStorage.QueueService/IQueueClientFactory.cs b/src/AzureStorage.QueueService/IQueueClientFactory.cs index c170f80..e68a262 100644 --- a/src/AzureStorage.QueueService/IQueueClientFactory.cs +++ b/src/AzureStorage.QueueService/IQueueClientFactory.cs @@ -1,7 +1,16 @@ namespace AzureStorage.QueueService; +/// +/// Hands out the queue registered under a name (or the default one). Each is created once and shared, the way +/// IHttpClientFactory shares handlers. Clients can also be injected directly: , +/// or for the default client, and the same with +/// [FromKeyedServices("name")] for a named one. +/// public interface IQueueClientFactory { - AzureStorageQueueClient GetQueueClient(string clientName); - AzureStorageQueueClient GetQueueClient(); -} \ No newline at end of file + /// No client with that name was registered, or its settings are incomplete. + IStorageQueue GetQueue(string clientName); + + /// No default client was registered, or its settings are incomplete. + IStorageQueue GetQueue(); +} diff --git a/src/AzureStorage.QueueService/IQueueMessageHandler.cs b/src/AzureStorage.QueueService/IQueueMessageHandler.cs new file mode 100644 index 0000000..66230f0 --- /dev/null +++ b/src/AzureStorage.QueueService/IQueueMessageHandler.cs @@ -0,0 +1,18 @@ +namespace AzureStorage.QueueService; + +/// +/// Handles one message for a processor registered with AddProcessor<TMessage, THandler>(). A new instance +/// is resolved from a new dependency-injection scope for every message, so scoped services work as they do in a +/// controller. +/// +/// +/// Return normally and the processor completes the message; throw and it is abandoned and retried later; or settle +/// it yourself with , or +/// and the processor leaves it alone. With +/// off, +/// can be null with set. The token is cancelled when the host shuts down. +/// +public interface IQueueMessageHandler +{ + ValueTask HandleAsync(QueueMessage message, CancellationToken cancellationToken); +} diff --git a/src/AzureStorage.QueueService/IQueueMessageOperations.cs b/src/AzureStorage.QueueService/IQueueMessageOperations.cs new file mode 100644 index 0000000..5bda45d --- /dev/null +++ b/src/AzureStorage.QueueService/IQueueMessageOperations.cs @@ -0,0 +1,25 @@ +namespace AzureStorage.QueueService; + +/// +/// The queue operations a needs. The library implements this over the SDK client; +/// a test that stubs supplies its own. +/// +public interface IQueueMessageOperations +{ + ValueTask DeleteAsync(string messageId, string popReceipt, CancellationToken cancellationToken); + + /// + /// Sets a new visibility timeout, measured from now. Zero makes the message visible immediately. + /// + ValueTask UpdateVisibilityAsync(string messageId, string popReceipt, TimeSpan visibilityTimeout, CancellationToken cancellationToken); + + /// + /// Copies the raw body to the poison queue, then deletes the original. + /// + ValueTask DeadLetterAsync(string messageId, string popReceipt, long dequeueCount, BinaryData rawBody, string? reason, CancellationToken cancellationToken); +} + +/// +/// What the service hands back after a visibility change: the new pop receipt and when the message becomes visible. +/// +public sealed record VisibilityReceipt(string PopReceipt, DateTimeOffset NextVisibleOn); diff --git a/src/AzureStorage.QueueService/IQueueMessageSerializer.cs b/src/AzureStorage.QueueService/IQueueMessageSerializer.cs new file mode 100644 index 0000000..ea0e358 --- /dev/null +++ b/src/AzureStorage.QueueService/IQueueMessageSerializer.cs @@ -0,0 +1,14 @@ +namespace AzureStorage.QueueService; + +/// +/// Turns message bodies into bytes and back. One per client; the default is System.Text.Json. +/// Base64 encoding is the SDK's job: set QueueClientSettings.ClientOptions.MessageEncoding for queues +/// shared with Azure Functions or older SDKs. +/// +public interface IQueueMessageSerializer +{ + BinaryData Serialize(T value); + + /// The body could not be read as . + T? Deserialize(BinaryData data); +} diff --git a/src/AzureStorage.QueueService/IQueueReceiver.cs b/src/AzureStorage.QueueService/IQueueReceiver.cs new file mode 100644 index 0000000..22eeb9a --- /dev/null +++ b/src/AzureStorage.QueueService/IQueueReceiver.cs @@ -0,0 +1,23 @@ +namespace AzureStorage.QueueService; + +/// +/// Receives messages from one queue. The caller settles each message ( +/// and friends) and owns the polling loop, backoff and error handling. For a loop the library runs for you, register +/// a processor instead. +/// +public interface IQueueReceiver : INamedQueue +{ + /// + /// Receives up to messages (1 to 32) and hides them from other consumers for + /// , which defaults to QueueClientSettings.VisibilityTimeout and then to + /// the service default of 30 seconds. A message whose body cannot be deserialized is still returned, with + /// set, so it can be dead-lettered rather than block the queue. + /// + /// The service rejected the receive. + ValueTask>> ReceiveAsync(int maxMessages = 1, TimeSpan? visibilityTimeout = null, CancellationToken cancellationToken = default); + + /// + /// Looks at up to messages without receiving them. + /// + ValueTask>> PeekAsync(int maxMessages = 1, CancellationToken cancellationToken = default); +} diff --git a/src/AzureStorage.QueueService/IQueueSender.cs b/src/AzureStorage.QueueService/IQueueSender.cs new file mode 100644 index 0000000..082fcf4 --- /dev/null +++ b/src/AzureStorage.QueueService/IQueueSender.cs @@ -0,0 +1,16 @@ +using Azure.Storage.Queues.Models; + +namespace AzureStorage.QueueService; + +/// +/// Sends messages to one queue. Depend on this when a class only sends; it is the smallest thing to fake. +/// +public interface IQueueSender : INamedQueue +{ + /// The service rejected the send. + ValueTask SendAsync(T message, SendOptions? options = null, CancellationToken cancellationToken = default); +} + +/// How long the message stays hidden after it is sent; by default it is visible immediately. +/// How long the message lives before the service discards it; the service default is 7 days. Use to never expire. +public sealed record SendOptions(TimeSpan? VisibilityTimeout = null, TimeSpan? TimeToLive = null); diff --git a/src/AzureStorage.QueueService/ISdkQueueClientFactory.cs b/src/AzureStorage.QueueService/ISdkQueueClientFactory.cs new file mode 100644 index 0000000..78f18da --- /dev/null +++ b/src/AzureStorage.QueueService/ISdkQueueClientFactory.cs @@ -0,0 +1,12 @@ +using Azure.Storage.Queues; + +namespace AzureStorage.QueueService; + +/// +/// Constructs the Azure SDK client for a set of settings. Internal seam so tests can hand the library a mock. +/// +internal interface ISdkQueueClientFactory +{ + /// Names the client (for example "Queue client 'orders'") so configuration errors can say which one is at fault. + QueueClient CreateClient(string clientDescription, QueueClientSettings settings); +} diff --git a/src/AzureStorage.QueueService/IStorageQueue.cs b/src/AzureStorage.QueueService/IStorageQueue.cs new file mode 100644 index 0000000..e46fb3d --- /dev/null +++ b/src/AzureStorage.QueueService/IStorageQueue.cs @@ -0,0 +1,16 @@ +using Azure.Storage.Queues.Models; + +namespace AzureStorage.QueueService; + +/// +/// Everything for one queue: sending, receiving and administration. What +/// hands out. Inject or instead when a class needs only one side. +/// +public interface IStorageQueue : IQueueSender, IQueueReceiver +{ + ValueTask CreateIfNotExistsAsync(IDictionary? metadata = null, CancellationToken cancellationToken = default); + + ValueTask ClearAsync(CancellationToken cancellationToken = default); + + ValueTask GetPropertiesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/AzureStorage.QueueService/JsonQueueMessageConverter.cs b/src/AzureStorage.QueueService/JsonQueueMessageConverter.cs deleted file mode 100644 index b2331ce..0000000 --- a/src/AzureStorage.QueueService/JsonQueueMessageConverter.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Text; -using System.Text.Json; - -namespace AzureStorage.QueueService; - -internal class JsonQueueMessageConverter : IMessageConverter -{ - private readonly JsonSerializerOptions _serializerOptions; - - public JsonQueueMessageConverter(JsonSerializerOptions? serializerOptions = null) - { - _serializerOptions = serializerOptions ?? new JsonSerializerOptions(); - } - - public TOutput? Convert(BinaryData input) - { - try - { - return JsonSerializer.Deserialize(input, _serializerOptions); - } - catch (Exception e) - { - throw new DeserializationException(Describe(e), e); - } - } - - public TOutput? Convert(string input) - { - // Messages written by this library (and by the v12 SDK by default) are plain JSON; messages written by - // older SDKs, Azure Functions or the portal are Base64-encoded. Try the text as-is first so that short - // JSON values which also happen to be valid Base64 ("true", "null", "1234") are not mangled, and only - // then fall back to decoding. - try - { - return JsonSerializer.Deserialize(input, _serializerOptions); - } - catch (JsonException) when (TryDecodeBase64(input, out var decoded)) - { - try - { - return JsonSerializer.Deserialize(decoded, _serializerOptions); - } - catch (Exception e) - { - throw new DeserializationException(Describe(e), e); - } - } - catch (Exception e) - { - throw new DeserializationException(Describe(e), e); - } - } - - public BinaryData Convert(TInput input) - { - // Honour the caller's serializer options on the way out as well as on the way in. - return BinaryData.FromObjectAsJson(input, _serializerOptions); - } - - private static string Describe(Exception e) => - $"Could not deserialize the queue message as {typeof(TOutput).Name}: {e.Message}"; - - private static bool TryDecodeBase64(string input, out string decoded) - { - // Decoded output is never longer than the encoded input. - var buffer = new byte[input.Length]; - if (System.Convert.TryFromBase64String(input, buffer, out var bytesWritten)) - { - decoded = Encoding.UTF8.GetString(buffer, 0, bytesWritten); - return true; - } - - decoded = string.Empty; - return false; - } -} diff --git a/src/AzureStorage.QueueService/JsonQueueMessageSerializer.cs b/src/AzureStorage.QueueService/JsonQueueMessageSerializer.cs new file mode 100644 index 0000000..69f5371 --- /dev/null +++ b/src/AzureStorage.QueueService/JsonQueueMessageSerializer.cs @@ -0,0 +1,27 @@ +using System.Text.Json; + +namespace AzureStorage.QueueService; + +internal sealed class JsonQueueMessageSerializer : IQueueMessageSerializer +{ + private readonly JsonSerializerOptions _options; + + public JsonQueueMessageSerializer(JsonSerializerOptions? options = null) + { + _options = options ?? new JsonSerializerOptions(); + } + + public BinaryData Serialize(T value) => BinaryData.FromObjectAsJson(value, _options); + + public T? Deserialize(BinaryData data) + { + try + { + return JsonSerializer.Deserialize(data, _options); + } + catch (Exception e) + { + throw new DeserializationException($"Could not deserialize the queue message as {typeof(T).Name}: {e.Message}", e); + } + } +} diff --git a/src/AzureStorage.QueueService/LeaseRenewal.cs b/src/AzureStorage.QueueService/LeaseRenewal.cs new file mode 100644 index 0000000..ac7f90f --- /dev/null +++ b/src/AzureStorage.QueueService/LeaseRenewal.cs @@ -0,0 +1,74 @@ +namespace AzureStorage.QueueService; + +/// +/// Renews one message's lease at a fixed interval until stopped. Only the wait between renewals is cancellable; +/// a renewal request already sent is always awaited, so the receipt the service holds is the one the message has. +/// +/// +/// One instance per delivery, with one end of life: stops the loop, waits for it, and +/// disposes the linked token source. The disposal matters — a linked source that is cancelled but never disposed +/// stays registered on its parent for the parent's lifetime, which for a long-lived processor is a leak of one +/// registration per message ever processed. +/// +internal sealed class LeaseRenewal : IAsyncDisposable +{ + private readonly CancellationTokenSource _stopWaiting; + private readonly Task _loop; + private bool _disposed; + + private LeaseRenewal(Func renewAsync, Func isSettled, TimeSpan interval, TimeProvider time, CancellationToken stopAll, Action onFailed) + { + _stopWaiting = CancellationTokenSource.CreateLinkedTokenSource(stopAll); + _loop = RunAsync(renewAsync, isSettled, interval, time, _stopWaiting.Token, onFailed); + } + + /// Renews the lease once. Must not observe a cancellation token: an accepted renewal changes the receipt, so the response must be read. + /// True once the message no longer needs renewing. + /// The processor-wide signal that ends every renewal, for example at shutdown. + public static LeaseRenewal Start(Func renewAsync, Func isSettled, TimeSpan interval, TimeProvider time, CancellationToken stopAll, Action onFailed) => + new(renewAsync, isSettled, interval, time, stopAll, onFailed); + + /// For lifetime tests only: the linked source whose disposal unregisters from the parent. + internal CancellationTokenSource LinkedSource => _stopWaiting; + + /// Stops scheduling renewals and waits for one already in flight. Safe to call more than once. + public async ValueTask StopAsync() + { + if (!_disposed && !_stopWaiting.IsCancellationRequested) _stopWaiting.Cancel(); + await _loop; + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + await StopAsync(); + _disposed = true; + _stopWaiting.Dispose(); + } + + private static async Task RunAsync(Func renewAsync, Func isSettled, TimeSpan interval, TimeProvider time, CancellationToken stopWaiting, Action onFailed) + { + try + { + while (true) + { + await Task.Delay(interval, time, stopWaiting); + if (isSettled()) return; + await renewAsync(); + } + } + catch (OperationCanceledException) + { + // Stopped; no more renewals are wanted. + } + catch (InvalidOperationException) when (isSettled()) + { + // The handler settled the message itself between the check and the renew. + } + catch (Exception ex) + { + // Stop renewing. The lease lapses and the message is redelivered; the handler's own completion may then fail. + onFailed(ex); + } + } +} diff --git a/src/AzureStorage.QueueService/LoggerMessageExtensions.cs b/src/AzureStorage.QueueService/LoggerMessageExtensions.cs index cc871f8..ef2afc8 100644 --- a/src/AzureStorage.QueueService/LoggerMessageExtensions.cs +++ b/src/AzureStorage.QueueService/LoggerMessageExtensions.cs @@ -2,19 +2,12 @@ namespace AzureStorage.QueueService; -public static partial class LoggerMessageExtensions +internal static partial class LoggerMessageExtensions { - [Obsolete("Use LogMessageProcessed, which includes the queue name. This method will be removed in 3.0.")] - [LoggerMessage(0, LogLevel.Information, "Processed message successfully, removing queue message id: {MessageId}")] - public static partial void LogProcessedMessage(this ILogger logger, string messageId); + // Event ids 0-2 were the 2.x messages without a queue name; removed in 3.0. - [Obsolete("Use LogMessagesReceived, which includes the queue name. This method will be removed in 3.0.")] - [LoggerMessage(1, LogLevel.Information, "Processing {MessageCount} storage queue message(s).")] - public static partial void LogMessageCount(this ILogger logger, int messageCount); - - [Obsolete("Use LogSendFailed, which includes the queue name and the exception. This method will be removed in 3.0.")] - [LoggerMessage(2, LogLevel.Error, "There was a problem sending the message. The error was: {ErrorMessage}")] - public static partial void LogSendError(this ILogger logger, string errorMessage); + [LoggerMessage(26, LogLevel.Warning, "Queue '{QueueName}': the message handler threw for message {MessageId} after settling it ({Settlement}); the settlement stands.")] + public static partial void LogHandlerFailedAfterSettling(this ILogger logger, string? queueName, string messageId, MessageSettlement settlement, Exception exception); [LoggerMessage(3, LogLevel.Information, "Queue '{QueueName}': processed message {MessageId} successfully; removing it from the queue.")] public static partial void LogMessageProcessed(this ILogger logger, string? queueName, string messageId); @@ -25,18 +18,57 @@ public static partial class LoggerMessageExtensions [LoggerMessage(5, LogLevel.Error, "Queue '{QueueName}': there was a problem sending the message.")] public static partial void LogSendFailed(this ILogger logger, string? queueName, Exception exception); - [LoggerMessage(6, LogLevel.Error, "Queue '{QueueName}': failed to receive messages. The error has been passed to the exception handler.")] + [LoggerMessage(6, LogLevel.Error, "Queue '{QueueName}': failed to receive messages.")] public static partial void LogReceiveFailed(this ILogger logger, string? queueName, Exception exception); - [LoggerMessage(7, LogLevel.Error, "Queue '{QueueName}': the message handler threw for message {MessageId} (dequeue count {DequeueCount}). The message was not deleted and will become visible again.")] + [LoggerMessage(7, LogLevel.Error, "Queue '{QueueName}': the message handler threw for message {MessageId} (dequeue count {DequeueCount}). The message was not completed and will be delivered again.")] public static partial void LogHandlerFailed(this ILogger logger, string? queueName, string messageId, long dequeueCount, Exception exception); [LoggerMessage(8, LogLevel.Warning, "Queue '{QueueName}': message {MessageId} was handled successfully but could not be deleted. The handler probably ran longer than the visibility timeout ({VisibilityTimeout}), so the message may be delivered again.")] public static partial void LogDeleteFailed(this ILogger logger, string? queueName, string messageId, TimeSpan visibilityTimeout, Exception exception); - [LoggerMessage(9, LogLevel.Error, "Queue '{QueueName}': the exception handler itself threw while handling message {MessageId}. Continuing with the next message.")] - public static partial void LogExceptionHandlerFailed(this ILogger logger, string? queueName, string messageId, Exception exception); + [LoggerMessage(11, LogLevel.Warning, "Queue '{QueueName}': message {MessageId} moved to '{PoisonQueue}' after {DequeueCount} attempt(s). Reason: {Reason}")] + public static partial void LogDeadLettered(this ILogger logger, string? queueName, string messageId, string? poisonQueue, long dequeueCount, string reason); + + [LoggerMessage(12, LogLevel.Information, "Processor {Processor}: started with {Workers} worker(s).")] + public static partial void LogProcessorStarted(this ILogger logger, string processor, int workers); + + [LoggerMessage(13, LogLevel.Error, "Processor {Processor}: receiving failed; trying again in {Backoff}.")] + public static partial void LogProcessorReceiveFailed(this ILogger logger, string processor, TimeSpan backoff, Exception exception); + + [LoggerMessage(14, LogLevel.Warning, "Processor {Processor}: could not renew the lease on message {MessageId}. It will become visible to other consumers when the lease lapses, even if this handler finishes.")] + public static partial void LogProcessorRenewFailed(this ILogger logger, string processor, string messageId, Exception exception); + + [LoggerMessage(15, LogLevel.Error, "Processor {Processor}: could not move message {MessageId} to the poison queue; abandoning it instead so it is retried.")] + public static partial void LogProcessorDeadLetterFailed(this ILogger logger, string processor, string messageId, Exception exception); + + [LoggerMessage(16, LogLevel.Warning, "Processor {Processor}: could not abandon message {MessageId}. It will become visible again when its lease lapses.")] + public static partial void LogProcessorAbandonFailed(this ILogger logger, string processor, string messageId, Exception exception); + + [LoggerMessage(17, LogLevel.Information, "Processor {Processor}: stopping; waiting up to {DrainTimeout} for {InFlight} handler(s) to finish.")] + public static partial void LogProcessorDraining(this ILogger logger, string processor, TimeSpan drainTimeout, int inFlight); + + [LoggerMessage(18, LogLevel.Warning, "Processor {Processor}: handlers did not finish within the drain timeout; cancelling them.")] + public static partial void LogProcessorDrainTimedOut(this ILogger logger, string processor); + + [LoggerMessage(19, LogLevel.Information, "Processor {Processor}: handler cancelled for message {MessageId}; the message was returned to the queue.")] + public static partial void LogProcessorHandlerCancelled(this ILogger logger, string processor, string messageId); + + [LoggerMessage(20, LogLevel.Information, "Processor {Processor}: stopped.")] + public static partial void LogProcessorStopped(this ILogger logger, string processor); + + [LoggerMessage(21, LogLevel.Error, "Processor {Processor}: delivering message {MessageId} failed outside the handler (resolving the handler, its scope, or an option callback); the message was abandoned for retry.")] + public static partial void LogProcessorDeliveryFailed(this ILogger logger, string processor, string messageId, Exception exception); + + [LoggerMessage(22, LogLevel.Error, "Processor {Processor}: the RetryDelay callback threw for dequeue count {DequeueCount}; using {Fallback} instead.")] + public static partial void LogProcessorRetryDelayFailed(this ILogger logger, string processor, long dequeueCount, TimeSpan fallback, Exception exception); + + [LoggerMessage(23, LogLevel.Warning, "Queue '{QueueName}': message {MessageId} (dequeue count {DequeueCount}) is not valid Base64 and was returned with Error set so it can be dead-lettered.")] + public static partial void LogDecodingFailed(this ILogger logger, string? queueName, string messageId, long dequeueCount); + + [LoggerMessage(24, LogLevel.Warning, "Processor {Processor}: {InFlight} handler(s) ignored cancellation for {Timeout} after the drain deadline; not waiting any longer. Their messages become visible again when their leases lapse.")] + public static partial void LogProcessorHandlersIgnoredCancellation(this ILogger logger, string processor, int inFlight, TimeSpan timeout); - [LoggerMessage(10, LogLevel.Warning, "Queue '{QueueName}': could not read the queue metadata; the handler will receive null metadata.")] - public static partial void LogMetadataFailed(this ILogger logger, string? queueName, Exception exception); + [LoggerMessage(25, LogLevel.Critical, "Processor {Processor}: a worker failed unexpectedly and will restart in {Backoff}. This is a bug in the library; please report it.")] + public static partial void LogProcessorWorkerFailed(this ILogger logger, string processor, TimeSpan backoff, Exception exception); } diff --git a/src/AzureStorage.QueueService/PeekedMessageExtensions.cs b/src/AzureStorage.QueueService/PeekedMessageExtensions.cs deleted file mode 100644 index a343637..0000000 --- a/src/AzureStorage.QueueService/PeekedMessageExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Azure.Storage.Queues.Models; - -namespace AzureStorage.QueueService; - -internal static class PeekedMessageExtensions -{ - public static IEnumerable Convert(this PeekedMessage[] peekedMessages, IMessageConverter messageConverter) - { - var results = new List(); - - foreach (var message in peekedMessages) - { - var convertedMessage = messageConverter.Convert(message.MessageText); - if (convertedMessage is not null) results.Add(convertedMessage); - } - - return results; - } -} \ No newline at end of file diff --git a/src/AzureStorage.QueueService/PeekedQueueMessage.cs b/src/AzureStorage.QueueService/PeekedQueueMessage.cs new file mode 100644 index 0000000..3567a4a --- /dev/null +++ b/src/AzureStorage.QueueService/PeekedQueueMessage.cs @@ -0,0 +1,32 @@ +namespace AzureStorage.QueueService; + +/// +/// A message looked at without receiving it. It has no pop receipt, so there is nothing to complete or abandon. +/// +public sealed class PeekedQueueMessage +{ + public PeekedQueueMessage(string messageId, long dequeueCount, DateTimeOffset? insertedOn, DateTimeOffset? expiresOn, BinaryData rawBody, T? body, DeserializationException? error) + { + MessageId = messageId; + DequeueCount = dequeueCount; + InsertedOn = insertedOn; + ExpiresOn = expiresOn; + RawBody = rawBody; + Body = body; + Error = error; + } + + public string MessageId { get; } + + public long DequeueCount { get; } + + public DateTimeOffset? InsertedOn { get; } + + public DateTimeOffset? ExpiresOn { get; } + + public BinaryData RawBody { get; } + + public T? Body { get; } + + public DeserializationException? Error { get; } +} diff --git a/src/AzureStorage.QueueService/QueueClientBuilder.cs b/src/AzureStorage.QueueService/QueueClientBuilder.cs deleted file mode 100644 index f7b14de..0000000 --- a/src/AzureStorage.QueueService/QueueClientBuilder.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Azure.Storage.Queues; - -namespace AzureStorage.QueueService; - -internal class QueueClientBuilder : IQueueClientBuilder -{ - public QueueClient CreateClient(string clientDescription, QueueClientSettings settings) - { - if (!string.IsNullOrEmpty(settings.ConnectionString)) - { - if (string.IsNullOrEmpty(settings.QueueName)) - throw new ApplicationException($"{clientDescription} has a ConnectionString but no QueueName."); - - return new QueueClient(settings.ConnectionString, settings.QueueName, settings.ClientOptions); - } - - if (settings.EndpointUri is not null && settings.TokenCredential is not null) - return new QueueClient(settings.EndpointUri, settings.TokenCredential, settings.ClientOptions); - - throw new ApplicationException( - $"{clientDescription} is not configured. Set ConnectionString and QueueName, " + - "or set EndpointUri (including the queue name) and TokenCredential."); - } -} diff --git a/src/AzureStorage.QueueService/QueueClientBuilderExtensions.cs b/src/AzureStorage.QueueService/QueueClientBuilderExtensions.cs new file mode 100644 index 0000000..29f7ac6 --- /dev/null +++ b/src/AzureStorage.QueueService/QueueClientBuilderExtensions.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AzureStorage.QueueService; + +public static class QueueClientBuilderExtensions +{ + /// + /// Adds to this client's settings. Runs after the delegate passed to AddClient. + /// + public static IQueueClientBuilder Configure(this IQueueClientBuilder builder, Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + builder.Services.Configure(builder.Name, configure); + return builder; + } + + /// + /// Serializes this client's messages with instead of the shared one. + /// + public static IQueueClientBuilder UseSerializer(this IQueueClientBuilder builder, IQueueMessageSerializer serializer) + { + ArgumentNullException.ThrowIfNull(serializer); + return builder.Configure(settings => settings.Serializer = serializer); + } + + /// + /// Serializes this client's messages with System.Text.Json using . + /// + public static IQueueClientBuilder UseJsonSerializer(this IQueueClientBuilder builder, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return builder.UseSerializer(new JsonQueueMessageSerializer(options)); + } + + /// + /// Runs a hosted processor for this client: the library receives messages of type + /// and hands each one to a new resolved from its own dependency-injection scope. + /// The processor owns the polling loop, backoff, visibility renewal, retries, the poison queue, cancellation and + /// shutdown; see for the knobs. A processor whose client is misconfigured fails + /// host startup on purpose; runtime failures never stop the host. + /// + public static IQueueClientBuilder AddProcessor(this IQueueClientBuilder builder, Action? configure = null) + where THandler : class, IQueueMessageHandler + { + ArgumentNullException.ThrowIfNull(builder); + + // Options are named per client + handler so the same handler type can serve two queues with different settings. + var optionsName = $"{builder.Name}|{typeof(THandler).FullName}"; + var clientName = builder.Name; + + builder.Services.AddOptions(optionsName).Configure(configure ?? (_ => { })); + builder.Services.TryAddScoped(); + + // Not AddHostedService: that de-duplicates by implementation type, and two processors of the same type for + // different queues must both run. + builder.Services.AddSingleton(provider => new QueueProcessor( + clientName, + optionsName, + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService>(), + provider.GetRequiredService(), + provider.GetService() ?? TimeProvider.System, + provider.GetService())); + + return builder; + } +} diff --git a/src/AzureStorage.QueueService/QueueClientFactory.cs b/src/AzureStorage.QueueService/QueueClientFactory.cs index a7010df..0c1d2ef 100644 --- a/src/AzureStorage.QueueService/QueueClientFactory.cs +++ b/src/AzureStorage.QueueService/QueueClientFactory.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using Azure.Storage.Queues; using AzureStorage.QueueService.Telemetry; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -8,31 +9,31 @@ namespace AzureStorage.QueueService; internal sealed class QueueClientFactory : IQueueClientFactory { // Lazy so two threads asking for the same client for the first time get one instance, not two. - private readonly ConcurrentDictionary> _clients = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary> _queues = new(StringComparer.Ordinal); private readonly QueueClientSettingsRegistry _registry; - private readonly IOptionsMonitor _settings; - private readonly IQueueClientBuilder _queueClientBuilder; + private readonly IOptionsFactory _settings; + private readonly ISdkQueueClientFactory _sdkClients; private readonly ILoggerFactory _loggerFactory; - private readonly IMessageConverter _messageConverter; - private readonly IOptions _telemetrySettings; + private readonly IQueueMessageSerializer _serializer; + private readonly QueueServiceTelemetrySettings _telemetry; public QueueClientFactory( QueueClientSettingsRegistry registry, - IOptionsMonitor settings, - IQueueClientBuilder queueClientBuilder, + IOptionsFactory settings, + ISdkQueueClientFactory sdkClients, ILoggerFactory loggerFactory, - IMessageConverter messageConverter, + IQueueMessageSerializer serializer, IOptions telemetrySettingsOptions) { _registry = registry; _settings = settings; - _queueClientBuilder = queueClientBuilder; + _sdkClients = sdkClients; _loggerFactory = loggerFactory; - _messageConverter = messageConverter; - _telemetrySettings = telemetrySettingsOptions; + _serializer = serializer; + _telemetry = telemetrySettingsOptions.Value; } - public AzureStorageQueueClient GetQueueClient(string clientName) + public IStorageQueue GetQueue(string clientName) { ArgumentNullException.ThrowIfNull(clientName); @@ -42,7 +43,7 @@ public AzureStorageQueueClient GetQueueClient(string clientName) ? "none" : string.Join(", ", _registry.ClientNames.Select(name => $"'{name}'")); - throw new ApplicationException( + throw new InvalidOperationException( $"No queue client named '{clientName}' has been registered (registered named clients: {registered}). " + $"Register it with services.AddAzureStorageQueueClient(x => x.AddClient(\"{clientName}\", ...))."); } @@ -50,37 +51,62 @@ public AzureStorageQueueClient GetQueueClient(string clientName) return GetOrCreate(clientName); } - public AzureStorageQueueClient GetQueueClient() + public IStorageQueue GetQueue() { if (!_registry.HasDefaultClient) { - throw new ApplicationException( + throw new InvalidOperationException( "No default queue client has been registered. Register one with " + - "services.AddAzureStorageQueueClient(x => x.AddDefaultClient(...)), or ask for a named client with GetQueueClient(name)."); + "services.AddAzureStorageQueueClient(x => x.AddDefaultClient(...)), or ask for a named client by name."); } return GetOrCreate(Options.DefaultName); } - private AzureStorageQueueClient GetOrCreate(string clientName) => - _clients.GetOrAdd(clientName, name => new Lazy(() => Create(name), LazyThreadSafetyMode.ExecutionAndPublication)).Value; + private StorageQueue GetOrCreate(string clientName) => + _queues.GetOrAdd(clientName, name => new Lazy(() => Create(name), LazyThreadSafetyMode.ExecutionAndPublication)).Value; - private AzureStorageQueueClient Create(string clientName) + private StorageQueue Create(string clientName) { - var settings = _settings.Get(clientName); + // A snapshot: the settings a queue was built with are the settings it keeps. + var settings = _settings.Create(clientName); var description = clientName == Options.DefaultName ? "The default queue client" : $"Queue client '{clientName}'"; - var queueClient = _queueClientBuilder.CreateClient(description, settings); - var messageConverter = settings.SerializerOptions is null - ? _messageConverter - : new JsonQueueMessageConverter(settings.SerializerOptions); + // The same check ValidateOnStart runs, so first use can never fail for something startup accepted. + if (settings.Validate() is { } error) throw new InvalidOperationException($"{description} {error}"); - return new AzureStorageQueueClient( - messageConverter, - queueClient, + var sdkClient = _sdkClients.CreateClient(description, settings); + var poisonQueueName = settings.EffectivePoisonQueueName!; + + QueueClient PoisonQueue() => _sdkClients.CreateClient($"{description} (poison queue '{poisonQueueName}')", ForSiblingQueue(settings, poisonQueueName)); + + return new StorageQueue( + sdkClient, + settings.Serializer ?? _serializer, _loggerFactory, - _telemetrySettings, + _telemetry, settings.VisibilityTimeout, - settings.CreateIfNotExists); + settings.CreateIfNotExists, + PoisonQueue); + } + + // The same account and credentials, pointed at another queue. A destination only: no poison policy of its own. + internal static QueueClientSettings ForSiblingQueue(QueueClientSettings settings, string queueName) => new() + { + ConnectionString = settings.ConnectionString, + QueueName = settings.UsesConnectionString ? queueName : null, + EndpointUri = settings.EndpointUri is null ? null : SiblingQueueUri(settings.EndpointUri, queueName), + TokenCredential = settings.TokenCredential, + ClientOptions = settings.ClientOptions, + }; + + /// Replaces the last path segment of a queue URI, whether or not it ends with a slash. + internal static Uri SiblingQueueUri(Uri queueUri, string queueName) + { + var builder = new UriBuilder(queueUri); + var path = builder.Path.TrimEnd('/'); + var lastSlash = path.LastIndexOf('/'); + builder.Path = (lastSlash >= 0 ? path[..(lastSlash + 1)] : "/") + queueName; + return builder.Uri; } } diff --git a/src/AzureStorage.QueueService/QueueClientSettings.cs b/src/AzureStorage.QueueService/QueueClientSettings.cs index 43d0c07..2b6c701 100644 --- a/src/AzureStorage.QueueService/QueueClientSettings.cs +++ b/src/AzureStorage.QueueService/QueueClientSettings.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using System.Text.Json; using Azure.Core; using Azure.Storage.Queues; @@ -8,8 +7,6 @@ namespace AzureStorage.QueueService; [ExcludeFromCodeCoverage] public class QueueClientSettings { - public const string SectionName = "AzureStorageQueueClientSettings"; - /// /// The storage account connection string. Use this together with , /// or use and instead. @@ -31,22 +28,125 @@ public class QueueClientSettings public TokenCredential? TokenCredential { get; set; } /// - /// How long a received message stays hidden from other consumers while your handler runs. When not set, the - /// service default of 30 seconds applies. If a handler runs longer than this the message becomes visible again, - /// another consumer can pick it up, and this consumer's delete fails. Allow for the slowest handler multiplied - /// by the number of messages received per call, since a batch is processed one message at a time. + /// How long a message received with stays hidden from other consumers + /// when the call does not say. When not set, the service default of 30 seconds applies. A processor uses its own + /// instead. /// public TimeSpan? VisibilityTimeout { get; set; } /// - /// JSON serializer options for this client only. When set, they take precedence over the options passed to - /// AddAzureStorageQueueClient for both sending and receiving. + /// Where dead-lettered messages go. Defaults to the queue name plus -poison; set it explicitly when the queue + /// name is too long for that (Azure allows 63 characters) or when a shared poison queue is wanted. /// - public JsonSerializerOptions? SerializerOptions { get; set; } + public string? PoisonQueueName { get; set; } + + /// + /// The serializer for this client only. Set with UseSerializer or UseJsonSerializer on the client + /// builder; when null, the serializer shared by all clients is used. + /// + public IQueueMessageSerializer? Serializer { get; set; } /// /// Options for the underlying Azure SDK client, such as the retry policy or /// for queues that carry Base64-encoded messages. /// public QueueClientOptions? ClientOptions { get; set; } + + internal static readonly TimeSpan MinVisibility = TimeSpan.FromSeconds(1); + internal static readonly TimeSpan MaxVisibility = TimeSpan.FromDays(7); + + /// True when the SDK client is built from the connection string; otherwise from the endpoint URI. + internal bool UsesConnectionString => !string.IsNullOrEmpty(ConnectionString); + + /// The queue named by the last path segment of , if any. + internal string? EndpointQueueName => + EndpointUri?.AbsolutePath.TrimEnd('/').Split('/').Last() is { Length: > 0 } segment ? segment : null; + + /// + /// The queue the SDK client will actually target, following the same precedence as construction: the connection + /// string path uses ; the endpoint path uses the queue in the URI and ignores . + /// + internal string? EffectiveQueueName => UsesConnectionString ? QueueName : EndpointQueueName; + + /// + /// Where dead-lettered messages go: , or the queue name plus -poison. An empty + /// name (a blank placeholder in configuration) counts as not set. + /// + internal string? EffectivePoisonQueueName => + HasPoisonQueueName ? PoisonQueueName : (EffectiveQueueName is { Length: > 0 } queue ? $"{queue}-poison" : null); + + private bool HasPoisonQueueName => !string.IsNullOrEmpty(PoisonQueueName); + + /// + /// What any SDK client the library builds needs: a way to connect and one queue to target. Used for source queues + /// and for poison destinations alike. Returns the problem, or null. + /// + internal string? ValidateConnection() + { + if (UsesConnectionString) + return string.IsNullOrEmpty(QueueName) ? "has a ConnectionString but no QueueName." : null; + + if (EndpointUri is null || TokenCredential is null) + return "is not configured. Set ConnectionString and QueueName, or set EndpointUri (including the queue name) and TokenCredential."; + + if (EndpointQueueName is null) + return "has an EndpointUri with no queue name in its path."; + + // With endpoint authentication the SDK takes the queue from the URI, so a different QueueName would be + // silently ignored and every check based on it would be checking the wrong queue. + if (!string.IsNullOrEmpty(QueueName) && !string.Equals(QueueName, EndpointQueueName, StringComparison.Ordinal)) + return $"has QueueName '{QueueName}' but EndpointUri points at queue '{EndpointQueueName}'. With EndpointUri the queue comes from the URI; remove QueueName or make them agree."; + + return null; + } + + /// + /// The connection plus the processing policy of a source queue: receive timing and the poison destination. + /// Shared by startup validation and source-queue construction so the two can never disagree. Not applied to + /// destination clients, which have no policy of their own. + /// + internal string? Validate() + { + if (ValidateConnection() is { } connectionProblem) return connectionProblem; + + if (VisibilityTimeout is { } visibility && (visibility < MinVisibility || visibility > MaxVisibility)) + return $"has a VisibilityTimeout of {visibility}; the service accepts 1 second to 7 days."; + + var poison = EffectivePoisonQueueName; + if (poison is not null && QueueNameRules.Check(poison) is { } problem) + { + return !HasPoisonQueueName + ?$"derives the poison queue name '{poison}' from the queue name, and {problem} Set PoisonQueueName to a valid name." + : $"has an invalid PoisonQueueName: {problem}"; + } + + if (poison is not null && string.Equals(poison, EffectiveQueueName, StringComparison.Ordinal)) + return $"has PoisonQueueName equal to its own queue name ('{poison}'). Dead-lettered messages would go straight back onto the queue with a fresh dequeue count; use a different poison queue."; + + return null; + } +} + +/// +/// Azure's rules for a queue name: 3 to 63 characters, lowercase letters, digits and single hyphens, starting and +/// ending with a letter or digit. +/// +internal static class QueueNameRules +{ + public const int MaxLength = 63; + + /// Returns a description of the problem, or null when the name is valid. + public static string? Check(string name) + { + if (name.Length is < 3 or > MaxLength) return $"'{name}' is {name.Length} characters; a queue name must be 3 to {MaxLength}."; + if (!char.IsAsciiLetterLower(name[0]) && !char.IsAsciiDigit(name[0])) return $"'{name}' must start with a lowercase letter or digit."; + if (!char.IsAsciiLetterLower(name[^1]) && !char.IsAsciiDigit(name[^1])) return $"'{name}' must end with a lowercase letter or digit."; + if (name.Contains("--")) return $"'{name}' must not contain consecutive hyphens."; + foreach (var c in name) + { + if (!char.IsAsciiLetterLower(c) && !char.IsAsciiDigit(c) && c != '-') return $"'{name}' may only contain lowercase letters, digits and hyphens."; + } + + return null; + } } diff --git a/src/AzureStorage.QueueService/QueueClientSettingsBuilder.cs b/src/AzureStorage.QueueService/QueueClientSettingsBuilder.cs index d79249d..4e237e5 100644 --- a/src/AzureStorage.QueueService/QueueClientSettingsBuilder.cs +++ b/src/AzureStorage.QueueService/QueueClientSettingsBuilder.cs @@ -5,6 +5,10 @@ namespace AzureStorage.QueueService; +/// +/// Registers clients inside AddAzureStorageQueueClient(queues => ...). Each AddClient / +/// AddDefaultClient returns a builder for that one client to attach a serializer or a processor to. +/// public class QueueClientSettingsBuilder { internal IServiceCollection Services { get; } @@ -18,57 +22,99 @@ internal QueueClientSettingsBuilder(IServiceCollection services, QueueClientSett } /// - /// Registers a named client. Get it from or inject it - /// directly with [FromKeyedServices("name")] AzureStorageQueueClient client. Registering the same name - /// again adds to its configuration rather than replacing it. + /// Registers a named client. Get it from , or inject + /// , or with + /// [FromKeyedServices("name")]. Registering the same name again adds to its configuration. /// - public QueueClientSettingsBuilder AddClient(string clientName, Action configure) + public IQueueClientBuilder AddClient(string clientName, Action configure) { ArgumentException.ThrowIfNullOrEmpty(clientName); ArgumentNullException.ThrowIfNull(configure); Registry.Add(clientName); Services.Configure(clientName, configure); - Services.TryAddKeyedSingleton(clientName, static (provider, key) => - provider.GetRequiredService().GetQueueClient((string)key!)); - Services.TryAddKeyedSingleton(clientName, static (provider, key) => - provider.GetRequiredKeyedService(key)); - return this; + Services.TryAddKeyedSingleton(clientName, static (provider, key) => + provider.GetRequiredService().GetQueue((string)key!)); + Services.TryAddKeyedSingleton(clientName, static (provider, key) => + provider.GetRequiredKeyedService(key)); + Services.TryAddKeyedSingleton(clientName, static (provider, key) => + provider.GetRequiredKeyedService(key)); + + if (Registry.ValidateOnStart) AddStartupValidation(clientName); + + return new QueueClientBuilder(clientName, Services); } /// /// Registers a named client bound from a configuration section, for example /// configuration.GetSection("Queues:Orders"). /// - public QueueClientSettingsBuilder AddClient(string clientName, IConfiguration configuration) + public IQueueClientBuilder AddClient(string clientName, IConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); return AddClient(clientName, settings => configuration.Bind(settings)); } /// - /// Registers the default client. Get it from or inject - /// (or ) directly. + /// Registers the default client. Get it from , or inject + /// , or directly. /// - public void AddDefaultClient(Action configure) + public IQueueClientBuilder AddDefaultClient(Action configure) { ArgumentNullException.ThrowIfNull(configure); Registry.HasDefaultClient = true; Services.Configure(Options.DefaultName, configure); - Services.TryAddSingleton(static provider => - provider.GetRequiredService().GetQueueClient()); - Services.TryAddSingleton(static provider => - provider.GetRequiredService()); + + Services.TryAddSingleton(static provider => provider.GetRequiredService().GetQueue()); + Services.TryAddSingleton(static provider => provider.GetRequiredService()); + Services.TryAddSingleton(static provider => provider.GetRequiredService()); + + if (Registry.ValidateOnStart) AddStartupValidation(Options.DefaultName); + + return new QueueClientBuilder(Options.DefaultName, Services); } /// /// Registers the default client bound from a configuration section. /// - public void AddDefaultClient(IConfiguration configuration) + public IQueueClientBuilder AddDefaultClient(IConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); - AddDefaultClient(settings => configuration.Bind(settings)); + return AddDefaultClient(settings => configuration.Bind(settings)); + } + + /// + /// Checks every registered client's settings when the host starts, so a missing connection string or a bad + /// poison queue name fails startup with a clear message instead of failing the first request. Applies to + /// clients registered before and after this call. + /// + public QueueClientSettingsBuilder ValidateOnStart() + { + if (Registry.ValidateOnStart) return this; + + Registry.ValidateOnStart = true; + // TryAddEnumerable, not TryAddSingleton: an app's own validator for these settings must not stop this one being added. + Services.TryAddEnumerable(ServiceDescriptor.Singleton, QueueClientSettingsValidator>()); + foreach (var name in Registry.ClientNames) AddStartupValidation(name); + if (Registry.HasDefaultClient) AddStartupValidation(Options.DefaultName); + + return this; + } + + private void AddStartupValidation(string clientName) => + Services.AddOptions(clientName).ValidateOnStart(); + + private sealed class QueueClientSettingsValidator : IValidateOptions + { + public ValidateOptionsResult Validate(string? name, QueueClientSettings options) + { + var error = options.Validate(); + if (error is null) return ValidateOptionsResult.Success; + + var description = string.IsNullOrEmpty(name) ? "The default queue client" : $"Queue client '{name}'"; + return ValidateOptionsResult.Fail($"{description} {error}"); + } } } diff --git a/src/AzureStorage.QueueService/QueueClientSettingsRegistry.cs b/src/AzureStorage.QueueService/QueueClientSettingsRegistry.cs index 127263d..acb6ca1 100644 --- a/src/AzureStorage.QueueService/QueueClientSettingsRegistry.cs +++ b/src/AzureStorage.QueueService/QueueClientSettingsRegistry.cs @@ -10,6 +10,8 @@ internal sealed class QueueClientSettingsRegistry public bool HasDefaultClient { get; set; } + public bool ValidateOnStart { get; set; } + public IReadOnlyCollection ClientNames => _clientNames; public void Add(string clientName) => _clientNames.Add(clientName); diff --git a/src/AzureStorage.QueueService/QueueMessage.cs b/src/AzureStorage.QueueService/QueueMessage.cs new file mode 100644 index 0000000..5cfd80f --- /dev/null +++ b/src/AzureStorage.QueueService/QueueMessage.cs @@ -0,0 +1,167 @@ +namespace AzureStorage.QueueService; + +/// +/// How a received message was settled. +/// +public enum MessageSettlement +{ + /// Still held; it becomes visible again when its lease lapses. + None, + + /// Deleted from the queue. + Completed, + + /// Given back to the queue to be delivered again. + Abandoned, + + /// Moved to the poison queue. + DeadLettered, +} + +/// +/// A message received from a queue, with its identity and the operations that settle it. The message stays +/// hidden from other consumers until its visibility timeout passes or it is completed, abandoned or dead-lettered. +/// +/// +/// Delivery is at-least-once: a lease can lapse before completion, and a completion can fail after the work is +/// done. Operations are serialized, so a renewal and a completion cannot race on the pop receipt, and every +/// operation uses the latest receipt. Once settled, further operations throw . +/// +public sealed class QueueMessage +{ + private readonly IQueueMessageOperations _operations; + private readonly SemaphoreSlim _gate = new(1, 1); + + /// + /// Public so a test that stubs can build messages; the library passes its real operations. + /// + public QueueMessage(QueueMessageInfo info, BinaryData rawBody, T? body, DeserializationException? error, IQueueMessageOperations operations) + { + ArgumentNullException.ThrowIfNull(info); + ArgumentNullException.ThrowIfNull(rawBody); + ArgumentNullException.ThrowIfNull(operations); + + MessageId = info.MessageId; + PopReceipt = info.PopReceipt; + DequeueCount = info.DequeueCount; + InsertedOn = info.InsertedOn; + ExpiresOn = info.ExpiresOn; + NextVisibleOn = info.NextVisibleOn; + RawBody = rawBody; + Body = body; + Error = error; + _operations = operations; + } + + public string MessageId { get; } + + /// Changes after and . + public string PopReceipt { get; private set; } + + /// How many times the message has been received, including this time. + public long DequeueCount { get; } + + public DateTimeOffset? InsertedOn { get; } + + public DateTimeOffset? ExpiresOn { get; } + + public DateTimeOffset? NextVisibleOn { get; private set; } + + public BinaryData RawBody { get; } + + /// Null when deserialization failed (see ) or when the body was JSON null. + public T? Body { get; private set; } + + /// Set when the body could not be decoded or deserialized. The message is still returned so it can be dead-lettered. + public DeserializationException? Error { get; private set; } + + // The processor receives raw and deserializes only after lease renewal is armed, so a slow serializer cannot + // let the lease lapse before the message is protected. + internal void SetBody(T? body, DeserializationException? error) + { + Body = body; + Error = error; + } + + /// How the message was settled, if it has been. + public MessageSettlement Settlement { get; private set; } + + /// True once the message has been completed, abandoned or dead-lettered. + public bool IsSettled => Settlement != MessageSettlement.None; + + /// Deletes the message from the queue. + public async ValueTask CompleteAsync(CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken); + try + { + ThrowIfSettled(); + await _operations.DeleteAsync(MessageId, PopReceipt, cancellationToken); + Settlement = MessageSettlement.Completed; + } + finally + { + _gate.Release(); + } + } + + /// + /// Gives the message back to the queue: visible again immediately, or after . + /// + public async ValueTask AbandonAsync(TimeSpan? visibilityTimeout = null, CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken); + try + { + ThrowIfSettled(); + var receipt = await _operations.UpdateVisibilityAsync(MessageId, PopReceipt, visibilityTimeout ?? TimeSpan.Zero, cancellationToken); + PopReceipt = receipt.PopReceipt; + NextVisibleOn = receipt.NextVisibleOn; + Settlement = MessageSettlement.Abandoned; + } + finally + { + _gate.Release(); + } + } + + /// Keeps the message hidden for another , measured from now. + public async ValueTask RenewLockAsync(TimeSpan visibilityTimeout, CancellationToken cancellationToken = default) + { + if (visibilityTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(visibilityTimeout), "A renewal must be longer than zero."); + + await _gate.WaitAsync(cancellationToken); + try + { + ThrowIfSettled(); + var receipt = await _operations.UpdateVisibilityAsync(MessageId, PopReceipt, visibilityTimeout, cancellationToken); + PopReceipt = receipt.PopReceipt; + NextVisibleOn = receipt.NextVisibleOn; + } + finally + { + _gate.Release(); + } + } + + /// Copies the raw body to the poison queue and deletes the message from this one. + public async ValueTask DeadLetterAsync(string? reason = null, CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken); + try + { + ThrowIfSettled(); + await _operations.DeadLetterAsync(MessageId, PopReceipt, DequeueCount, RawBody, reason, cancellationToken); + Settlement = MessageSettlement.DeadLettered; + } + finally + { + _gate.Release(); + } + } + + private void ThrowIfSettled() + { + if (IsSettled) throw new InvalidOperationException($"Message {MessageId} has already been settled ({Settlement})."); + } +} diff --git a/src/AzureStorage.QueueService/QueueMessageInfo.cs b/src/AzureStorage.QueueService/QueueMessageInfo.cs new file mode 100644 index 0000000..14dafcb --- /dev/null +++ b/src/AzureStorage.QueueService/QueueMessageInfo.cs @@ -0,0 +1,12 @@ +namespace AzureStorage.QueueService; + +/// +/// The service-side identity of a received message. +/// +public sealed record QueueMessageInfo( + string MessageId, + string PopReceipt, + long DequeueCount, + DateTimeOffset? InsertedOn, + DateTimeOffset? ExpiresOn, + DateTimeOffset? NextVisibleOn); diff --git a/src/AzureStorage.QueueService/QueueProcessor.cs b/src/AzureStorage.QueueService/QueueProcessor.cs new file mode 100644 index 0000000..7e98a6a --- /dev/null +++ b/src/AzureStorage.QueueService/QueueProcessor.cs @@ -0,0 +1,473 @@ +using AzureStorage.QueueService.Telemetry; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Diagnostics; + +namespace AzureStorage.QueueService; + +/// +/// The hosted loop behind AddProcessor<TMessage, THandler>(): +/// workers, each of which receives one message, delivers it, and goes back for the next. One instance per registration. +/// +/// +/// Lifecycle: snapshots and validates the options and resolves the queue (a +/// misconfigured client fails startup on purpose). The workers start when the host reports +/// — the signal that every startup callback succeeded — so a +/// slow start does not hold leases and a start that fails part-way never polls at all; without a host lifetime (a +/// direct test) they start in . On shutdown, receiving stops; running handlers get +/// to finish; then their token is cancelled and lease renewal +/// stops in the same moment; a handler that ignores its token gets the same time again, after which shutdown stops +/// waiting for it. If the host's own shutdown deadline arrives first, the handlers are cancelled at that moment and +/// returns rather than throwing, as BackgroundService does. The cancellations happen no +/// matter how exits. Nothing that happens at runtime stops the host, and a worker that fails +/// unexpectedly is logged loudly and restarted rather than silently lost. +/// +internal sealed class QueueProcessor : IHostedService, IHostedLifecycleService, IDisposable + where THandler : class, IQueueMessageHandler +{ + private readonly string _clientName; + private readonly string _optionsName; + private readonly IQueueClientFactory _factory; + private readonly IServiceScopeFactory _scopes; + private readonly IOptionsFactory _optionsFactory; + private readonly ILogger _logger; + private readonly TimeProvider _time; + private readonly IHostApplicationLifetime? _lifetime; + + private readonly CancellationTokenSource _stopReceiving = new(); + private readonly CancellationTokenSource _cancelHandlers = new(); + private readonly CancellationTokenSource _stopRenewals = new(); + + // Taken once, up front: reading Token from a disposed source throws, and a worker can still be mid-delivery when + // a host that gave up waiting disposes the processor. + private readonly CancellationToken _handlerToken; + private readonly CancellationToken _renewalToken; + private readonly object _startLock = new(); + private CancellationTokenRegistration _startWhenApplicationStarted; + + private QueueProcessorOptions _options = new(); + private IStorageQueue? _queue; + private Task[]? _workers; + private string _name; + + public QueueProcessor( + string clientName, + string optionsName, + IQueueClientFactory factory, + IServiceScopeFactory scopes, + IOptionsFactory optionsFactory, + ILoggerFactory loggerFactory, + TimeProvider time, + IHostApplicationLifetime? lifetime = null) + { + _clientName = clientName; + _optionsName = optionsName; + _factory = factory; + _scopes = scopes; + _optionsFactory = optionsFactory; + _logger = loggerFactory.CreateLogger($"AzureStorage.QueueService.QueueProcessor<{typeof(TMessage).Name}, {typeof(THandler).Name}>"); + _time = time; + _lifetime = lifetime; + _name = typeof(THandler).Name; + _handlerToken = _cancelHandlers.Token; + _renewalToken = _stopRenewals.Token; + } + + private string QueueName => _queue?.QueueName ?? _clientName; + + // ------------------------------------------------------------------------------------------------------------ + // Lifecycle + // ------------------------------------------------------------------------------------------------------------ + + public Task StartAsync(CancellationToken cancellationToken) + { + // A snapshot: these are the options the processor runs with for its whole life. + _options = _optionsFactory.Create(_optionsName); + _options.Validate($"Processor {_name}"); + + // Throws for a misconfigured client. That fails host startup, which is the right time to find out. + _queue = _clientName == Options.DefaultName ? _factory.GetQueue() : _factory.GetQueue(_clientName); + _name = $"{typeof(THandler).Name} on queue '{_queue.QueueName}'"; + + // Poll only once the whole application has started. If any later startup step fails, this never fires. + if (_lifetime is not null) + _startWhenApplicationStarted = _lifetime.ApplicationStarted.Register(StartWorkers); + + return Task.CompletedTask; + } + + public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StartedAsync(CancellationToken cancellationToken) + { + if (_queue is null) throw new InvalidOperationException("StartAsync must run before StartedAsync."); + + // No host lifetime to wait for: this callback is the best signal there is. + if (_lifetime is null) StartWorkers(); + + return Task.CompletedTask; + } + + private void StartWorkers() + { + lock (_startLock) + { + if (_workers is not null || _stopReceiving.IsCancellationRequested) return; + + _logger.LogProcessorStarted(_name, _options.MaxConcurrentMessages); + _workers = Enumerable.Range(0, _options.MaxConcurrentMessages) + .Select(_ => Task.Run(() => WorkerAsync(_stopReceiving.Token), CancellationToken.None)) + .ToArray(); + } + } + + public Task StoppingAsync(CancellationToken cancellationToken) + { + _stopReceiving.Cancel(); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _stopReceiving.Cancel(); + if (_workers is null) return; + + try + { + var all = Task.WhenAll(_workers); + if (all.IsCompleted) return; + + _logger.LogProcessorDraining(_name, _options.ShutdownDrainTimeout, Running()); + if (await FinishedWithinAsync(all, _options.ShutdownDrainTimeout, cancellationToken)) return; + + // The drain deadline: cancel the handlers and, in the same moment, stop scheduling renewals, so a handler + // that ignores its token cannot keep its lease alive for ever. A renewal already in flight still completes. + _logger.LogProcessorDrainTimedOut(_name); + _cancelHandlers.Cancel(); + _stopRenewals.Cancel(); + if (await FinishedWithinAsync(all, _options.ShutdownDrainTimeout, cancellationToken)) return; + + // Bounded: a handler that ignores cancellation is application code the processor cannot terminate. + // When the host's deadline cut the wait short, there was no second wait to report on. + if (!cancellationToken.IsCancellationRequested) + _logger.LogProcessorHandlersIgnoredCancellation(_name, Running(), _options.ShutdownDrainTimeout); + } + finally + { + // Unconditional: however this method exits — drained, cancelled by the host's deadline, or faulted — + // nothing the processor owns keeps running afterwards. + _cancelHandlers.Cancel(); + _stopRenewals.Cancel(); + _logger.LogProcessorStopped(_name); + } + } + + public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() + { + // A host that failed part-way through startup disposes its services without ever calling StopAsync. + // Cancel first: a disposed, uncancelled source tells nobody anything, and workers may already be polling. + _startWhenApplicationStarted.Dispose(); + _stopReceiving.Cancel(); + _cancelHandlers.Cancel(); + _stopRenewals.Cancel(); + + _stopReceiving.Dispose(); + _cancelHandlers.Dispose(); + _stopRenewals.Dispose(); + } + + private int Running() => _workers?.Count(w => !w.IsCompleted) ?? 0; + + private async Task FinishedWithinAsync(Task task, TimeSpan timeout, CancellationToken cancellationToken) + { + try + { + await task.WaitAsync(timeout, _time, cancellationToken); + return true; + } + catch (TimeoutException) + { + return false; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The host's shutdown deadline: stop waiting, but StopAsync still cancels everything on the way out. + return false; + } + } + + // ------------------------------------------------------------------------------------------------------------ + // A worker: receive one, deliver it, repeat + // ------------------------------------------------------------------------------------------------------------ + + private async Task WorkerAsync(CancellationToken stop) + { + while (!stop.IsCancellationRequested) + { + try + { + await ReceiveAndDeliverUntilStoppedAsync(stop); + return; + } + catch (OperationCanceledException) when (stop.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + // Nothing in the loop is supposed to throw. If something does, say so loudly and start again + // rather than silently running one worker short for the rest of the process's life. + _logger.LogProcessorWorkerFailed(_name, _options.ErrorBackoffMax, ex); + if (!await DelayAsync(_options.ErrorBackoffMax, stop)) return; + } + } + } + + private async Task ReceiveAndDeliverUntilStoppedAsync(CancellationToken stop) + { + var queue = _queue!; + var owned = queue as IOwnedReceiver; + var emptyBackoff = _options.EmptyQueueBackoffMin; + var errorBackoff = _options.EmptyQueueBackoffMin; + + while (!stop.IsCancellationRequested) + { + QueueMessage? message; + try + { + // The library's own queue hands the message over before deserializing it, so renewal is armed first. + // Any other IStorageQueue (a fake in a test) deserializes on receive. + message = owned is not null + ? await owned.ReceiveUndeserializedAsync(_options.VisibilityTimeout, stop) + : (await queue.ReceiveAsync(1, _options.VisibilityTimeout, stop)).FirstOrDefault(); + errorBackoff = _options.EmptyQueueBackoffMin; + } + catch (OperationCanceledException) when (stop.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + // Transport failures, a missing queue, no permission: log, back off, keep going. + _logger.LogProcessorReceiveFailed(_name, errorBackoff, ex); + if (!await DelayAsync(errorBackoff, stop)) return; + errorBackoff = Min(errorBackoff * 2, _options.ErrorBackoffMax); + continue; + } + + if (message is null) + { + if (!await DelayAsync(emptyBackoff, stop)) return; + emptyBackoff = Min(emptyBackoff * 2, _options.EmptyQueueBackoffMax); + continue; + } + + emptyBackoff = _options.EmptyQueueBackoffMin; + await DeliverAsync(message, owned); + } + } + + private async Task DelayAsync(TimeSpan delay, CancellationToken stop) + { + try + { + await Task.Delay(delay, _time, stop); + return true; + } + catch (OperationCanceledException) + { + return false; + } + } + + private static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b; + + // ------------------------------------------------------------------------------------------------------------ + // One delivery: the complete lifecycle of one message, with one exception boundary. Never throws. + // ------------------------------------------------------------------------------------------------------------ + + private async Task DeliverAsync(QueueMessage message, IOwnedReceiver? deserializer) + { + var start = Stopwatch.GetTimestamp(); + var outcome = "abandoned"; + + var tags = QueueTags(); + tags.Add(QueueServiceDiagnostics.Names.MessageId, message.MessageId); + tags.Add(QueueServiceDiagnostics.Names.DequeueCount, message.DequeueCount); + using Activity? activity = QueueServiceDiagnostics.Activities.StartProcessingMessageActivity(tags); + + // Armed before anything else — including deserialization — so nothing that happens to this message + // while it is ours can let the lease lapse. + await using var renewal = _options.RenewVisibility + ? LeaseRenewal.Start( + () => message.RenewLockAsync(_options.VisibilityTimeout, CancellationToken.None), + () => message.IsSettled, + TimeSpan.FromTicks(_options.VisibilityTimeout.Ticks / 2), + _time, + _renewalToken, + ex => _logger.LogProcessorRenewFailed(_name, message.MessageId, ex)) + : null; + + try + { + deserializer?.Deserialize(message); + + if (message.Error is not null && _options.DeadLetterOnDeserializationFailure) + { + await StopRenewalAsync(renewal); + outcome = await DeadLetterOrAbandonAsync(message, $"the body could not be read: {message.Error.Message}"); + return; + } + + if (message.DequeueCount > _options.MaxDequeueCount) + { + await StopRenewalAsync(renewal); + outcome = await DeadLetterOrAbandonAsync(message, $"delivered {message.DequeueCount} times; the limit is {_options.MaxDequeueCount}"); + return; + } + + try + { + // Resolving the handler is inside the boundary: a constructor that throws is a failed delivery too. + await using var scope = _scopes.CreateAsyncScope(); + var handler = scope.ServiceProvider.GetRequiredService(); + await handler.HandleAsync(message, _handlerToken); + } + catch (OperationCanceledException) when (_handlerToken.IsCancellationRequested && !message.IsSettled) + { + await StopRenewalAsync(renewal); + _logger.LogProcessorHandlerCancelled(_name, message.MessageId); + if (!message.IsSettled) await AbandonSafelyAsync(message, TimeSpan.Zero); + return; + } + catch (Exception ex) when (!message.IsSettled) + { + await StopRenewalAsync(renewal); + _logger.LogHandlerFailed(QueueName, message.MessageId, message.DequeueCount, ex); + activity?.AddException(ex); + QueueServiceDiagnostics.Metrics.MessageFailures.Add(1, QueueTags()); + if (!message.IsSettled) await AbandonSafelyAsync(message, RetryDelayFor(message.DequeueCount)); + return; + } + catch (Exception ex) + { + // The handler settled the message and then threw. The settlement stands; report it as what it was. + _logger.LogHandlerFailedAfterSettling(QueueName, message.MessageId, message.Settlement, ex); + activity?.AddException(ex); + } + + // Wait for any renewal already in flight so completion uses the receipt the service holds. + await StopRenewalAsync(renewal); + + switch (message.Settlement) + { + case MessageSettlement.Completed: + outcome = "completed"; + break; + case MessageSettlement.Abandoned: + outcome = "abandoned_by_handler"; + return; + case MessageSettlement.DeadLettered: + outcome = "dead_lettered_by_handler"; + return; + case MessageSettlement.None when _options.AutoComplete: + try + { + // Not tied to the shutdown token: cancelling a delete after the handler succeeded only redelivers finished work. + await message.CompleteAsync(CancellationToken.None); + outcome = "completed"; + } + catch (Exception ex) + { + _logger.LogDeleteFailed(QueueName, message.MessageId, _options.VisibilityTimeout, ex); + activity?.AddException(ex); + QueueServiceDiagnostics.Metrics.MessageFailures.Add(1, QueueTags()); + outcome = "complete_failed"; + return; + } + break; + default: + // AutoComplete is off and the handler returned without settling: nothing was removed. + outcome = "left_to_handler"; + return; + } + + _logger.LogMessageProcessed(QueueName, message.MessageId); + QueueServiceDiagnostics.Metrics.MessagesProcessed.Add(1, QueueTags()); + } + catch (Exception ex) + { + // Anything the inner boundaries did not cover: scope disposal, an option callback, a logging sink. + // A delivery must never fault silently and leave its message in limbo. + _logger.LogProcessorDeliveryFailed(_name, message.MessageId, ex); + activity?.AddException(ex); + QueueServiceDiagnostics.Metrics.MessageFailures.Add(1, QueueTags()); + if (!message.IsSettled) await AbandonSafelyAsync(message, QueueProcessorOptions.MaxRetryDelay); + outcome = "abandoned"; + } + finally + { + await StopRenewalAsync(renewal); + + var finalTags = QueueTags(); + finalTags.Add(QueueServiceDiagnostics.Names.QueueProcessSuccess, outcome is "completed" or "left_to_handler"); + finalTags.Add(QueueServiceDiagnostics.Names.Outcome, outcome); + QueueServiceDiagnostics.Metrics.MessageProcessingDuration.Record(Stopwatch.GetElapsedTime(start).TotalSeconds, finalTags); + activity?.SetTag(QueueServiceDiagnostics.Names.Outcome, outcome); + } + } + + private static ValueTask StopRenewalAsync(LeaseRenewal? renewal) => renewal?.StopAsync() ?? ValueTask.CompletedTask; + + private TimeSpan RetryDelayFor(long dequeueCount) + { + TimeSpan delay; + try + { + delay = _options.RetryDelay(dequeueCount); + } + catch (Exception ex) + { + _logger.LogProcessorRetryDelayFailed(_name, dequeueCount, QueueProcessorOptions.MaxRetryDelay, ex); + return QueueProcessorOptions.MaxRetryDelay; + } + + // The service accepts 0 to 7 days; anything else would make the abandon itself fail. + return delay < TimeSpan.Zero ? TimeSpan.Zero : Min(delay, QueueProcessorOptions.MaxVisibility); + } + + private async Task DeadLetterOrAbandonAsync(QueueMessage message, string reason) + { + try + { + await message.DeadLetterAsync(reason, CancellationToken.None); + QueueServiceDiagnostics.Metrics.MessageFailures.Add(1, QueueTags()); + return "dead_lettered"; + } + catch (Exception ex) + { + // No permission to create the poison queue, or the lease lapsed. Never lose the message silently. + _logger.LogProcessorDeadLetterFailed(_name, message.MessageId, ex); + if (!message.IsSettled) await AbandonSafelyAsync(message, RetryDelayFor(message.DequeueCount)); + return "abandoned"; + } + } + + private async ValueTask AbandonSafelyAsync(QueueMessage message, TimeSpan delay) + { + try + { + await message.AbandonAsync(delay, CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogProcessorAbandonFailed(_name, message.MessageId, ex); + } + } + + private TagList QueueTags() => new() { new(QueueServiceDiagnostics.Names.QueueName, QueueName) }; +} diff --git a/src/AzureStorage.QueueService/QueueProcessorOptions.cs b/src/AzureStorage.QueueService/QueueProcessorOptions.cs new file mode 100644 index 0000000..d8239aa --- /dev/null +++ b/src/AzureStorage.QueueService/QueueProcessorOptions.cs @@ -0,0 +1,96 @@ +namespace AzureStorage.QueueService; + +/// +/// How a processor registered with AddProcessor<TMessage, THandler>() runs. The processor is a set of +/// workers; each receives one message, delivers it, and goes back for the next. +/// +public sealed class QueueProcessorOptions +{ + internal static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(5); + + /// The longest wait the timer APIs accept (a little under 50 days). + internal static readonly TimeSpan MaxTimer = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + + /// The longest visibility timeout the service accepts. + internal static readonly TimeSpan MaxVisibility = TimeSpan.FromDays(7); + + /// How many messages may be handled at once: one worker each, with its own scope and its own lease renewal. + public int MaxConcurrentMessages { get; set; } = 1; + + /// + /// How long a received message stays hidden while a handler runs: 1 second to 7 days, in whole seconds (the + /// service drops fractions). Renewed while the handler is still running when is on, + /// so it only needs to cover the renewal interval, not the whole handler. + /// + public TimeSpan VisibilityTimeout { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// Renew the lease at half while the handler runs. Delivery is still at-least-once; + /// renewal closes the avoidable window where a slow handler lets another consumer take the same message. + /// + public bool RenewVisibility { get; set; } = true; + + /// + /// Complete the message when the handler returns. Off, a message the handler returns without settling stays + /// hidden until its lease lapses and is then delivered again. Either way, a message whose handler throws is abandoned for retry. + /// + public bool AutoComplete { get; set; } = true; + + /// A message received more times than this is moved to the poison queue without calling the handler. + public int MaxDequeueCount { get; set; } = 5; + + /// A message whose body cannot be decoded or deserialized is moved to the poison queue without calling the handler. + public bool DeadLetterOnDeserializationFailure { get; set; } = true; + + /// + /// How long a failed message stays hidden before it is retried, given its dequeue count. The default doubles from + /// 5 seconds and caps at 5 minutes: 5 s, 10 s, 20 s, 40 s, ... If the callback throws, 5 minutes is used; a + /// result outside 0 to 7 days is clamped. + /// + public Func RetryDelay { get; set; } = dequeueCount => + { + var exponent = Math.Clamp(dequeueCount - 1, 0, 10); + var delay = TimeSpan.FromSeconds(5 * Math.Pow(2, exponent)); + return delay < MaxRetryDelay ? delay : MaxRetryDelay; + }; + + /// + /// How long a worker waits after an empty receive before polling again; doubles up to + /// and resets on the next message. Each worker polls on its own, so an idle processor with N workers makes N requests per interval. + /// + public TimeSpan EmptyQueueBackoffMin { get; set; } = TimeSpan.FromSeconds(1); + + public TimeSpan EmptyQueueBackoffMax { get; set; } = TimeSpan.FromSeconds(30); + + /// The longest wait after a failed receive; the wait doubles from and resets on the next success. + public TimeSpan ErrorBackoffMax { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// How long shutdown waits for handlers already running before cancelling their token and stopping their lease + /// renewal. A handler that ignores its token is given the same time again, after which shutdown stops waiting + /// for it; its message becomes visible again when its lease lapses. + /// + public TimeSpan ShutdownDrainTimeout { get; set; } = TimeSpan.FromSeconds(30); + + internal void Validate(string processorName) + { + if (MaxConcurrentMessages < 1) Fail(processorName, "MaxConcurrentMessages must be at least 1."); + // The SDK sends whole seconds, so anything under a second becomes 0 and the service rejects every receive. + if (VisibilityTimeout < TimeSpan.FromSeconds(1) || VisibilityTimeout > MaxVisibility) Fail(processorName, $"VisibilityTimeout must be between 1 second and 7 days (it is {VisibilityTimeout})."); + // Renewal runs at half the timeout; with a fraction the service's lease is shorter than that half assumes. + if (VisibilityTimeout.Ticks % TimeSpan.TicksPerSecond != 0) Fail(processorName, $"VisibilityTimeout must be a whole number of seconds (it is {VisibilityTimeout})."); + if (MaxDequeueCount < 1) Fail(processorName, "MaxDequeueCount must be at least 1."); + if (EmptyQueueBackoffMin <= TimeSpan.Zero || EmptyQueueBackoffMax < EmptyQueueBackoffMin) Fail(processorName, "EmptyQueueBackoffMin must be positive and no larger than EmptyQueueBackoffMax."); + if (ErrorBackoffMax < EmptyQueueBackoffMin) Fail(processorName, "ErrorBackoffMax must be at least EmptyQueueBackoffMin."); + if (ShutdownDrainTimeout < TimeSpan.Zero) Fail(processorName, "ShutdownDrainTimeout cannot be negative."); + if (RetryDelay is null) Fail(processorName, "RetryDelay must be set."); + + // Anything a timer waits on must be within what the timer APIs accept, or the wait throws at runtime instead. + foreach (var (name, value) in new[] { (nameof(EmptyQueueBackoffMax), EmptyQueueBackoffMax), (nameof(ErrorBackoffMax), ErrorBackoffMax), (nameof(ShutdownDrainTimeout), ShutdownDrainTimeout) }) + { + if (value > MaxTimer) Fail(processorName, $"{name} must be no longer than {MaxTimer.TotalDays:0} days (it is {value})."); + } + } + + private static void Fail(string processorName, string problem) => throw new InvalidOperationException($"{processorName}: {problem}"); +} diff --git a/src/AzureStorage.QueueService/SdkQueueClientFactory.cs b/src/AzureStorage.QueueService/SdkQueueClientFactory.cs new file mode 100644 index 0000000..85a553a --- /dev/null +++ b/src/AzureStorage.QueueService/SdkQueueClientFactory.cs @@ -0,0 +1,35 @@ +using Azure.Storage.Queues; + +namespace AzureStorage.QueueService; + +internal sealed class SdkQueueClientFactory : ISdkQueueClientFactory +{ + public QueueClient CreateClient(string clientDescription, QueueClientSettings settings) + { + // Only what this client needs to exist. Source-queue policy (poison destination, receive timing) is the + // factory's job; applying it here would demand a poison queue for the poison queue. + var error = settings.ValidateConnection(); + if (error is not null) throw new InvalidOperationException($"{clientDescription} {error}"); + + QueueClient Create() => settings.UsesConnectionString + ? new QueueClient(settings.ConnectionString, settings.QueueName, settings.ClientOptions) + : new QueueClient(settings.EndpointUri, settings.TokenCredential, settings.ClientOptions); + + if (settings.ClientOptions is not { MessageEncoding: QueueMessageEncoding.Base64 } options) return Create(); + + // The SDK captures event handlers during construction. Restore caller-owned options immediately afterward, + // including when construction fails; serialize library construction with the same options to avoid duplicates. + lock (options) + { + options.MessageDecodingFailed += DecodingFailures.OnDecodingFailedAsync; + try + { + return Create(); + } + finally + { + options.MessageDecodingFailed -= DecodingFailures.OnDecodingFailedAsync; + } + } + } +} diff --git a/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs b/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs index 22478c3..7ac8b51 100644 --- a/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs +++ b/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs @@ -12,8 +12,8 @@ public static class ServiceCollectionExtensions /// Can be called more than once; later calls add clients to the same registration. /// /// - /// Serializer options shared by every client that does not set its own . - /// When omitted, an you registered yourself is used if present. + /// JSON options shared by every client that does not set its own serializer with UseSerializer / + /// UseJsonSerializer. When omitted, an you registered yourself is used if present. /// public static IServiceCollection AddAzureStorageQueueClient(this IServiceCollection services, Action configure, JsonSerializerOptions? serializerOptions = null) { @@ -23,12 +23,12 @@ public static IServiceCollection AddAzureStorageQueueClient(this IServiceCollect var registry = GetOrAddRegistry(services); services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); if (serializerOptions is not null) - services.Replace(ServiceDescriptor.Singleton(new JsonQueueMessageConverter(serializerOptions))); + services.Replace(ServiceDescriptor.Singleton(new JsonQueueMessageSerializer(serializerOptions))); else - services.TryAddSingleton(new JsonQueueMessageConverter()); + services.TryAddSingleton(new JsonQueueMessageSerializer()); configure(new QueueClientSettingsBuilder(services, registry)); @@ -41,8 +41,7 @@ public static IServiceCollection ConfigureQueueServiceTelemetry(this IServiceCol return services; } - // One registry per service collection. It used to be a process-wide static, so every host built in the same - // process (a second host, a test host) silently shared and overwrote one set of client settings. + // One registry per service collection, so two hosts in one process never share client settings. private static QueueClientSettingsRegistry GetOrAddRegistry(IServiceCollection services) { foreach (var descriptor in services) diff --git a/src/AzureStorage.QueueService/StorageQueue.cs b/src/AzureStorage.QueueService/StorageQueue.cs new file mode 100644 index 0000000..a696946 --- /dev/null +++ b/src/AzureStorage.QueueService/StorageQueue.cs @@ -0,0 +1,329 @@ +using Azure; +using Azure.Storage.Queues; +using Azure.Storage.Queues.Models; +using AzureStorage.QueueService.Telemetry; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using SdkQueueMessage = Azure.Storage.Queues.Models.QueueMessage; + +namespace AzureStorage.QueueService; + +/// +/// One queue over one SDK client. Thread-safe and long-lived; the factory keeps one per registered name. +/// Also implements the operations a uses to settle itself. +/// +internal sealed class StorageQueue : IStorageQueue, IQueueMessageOperations, IOwnedReceiver +{ + private const int ServiceMaxMessages = 32; + + private readonly QueueClient _client; + private readonly IQueueMessageSerializer _serializer; + private readonly ILogger _logger; + private readonly QueueServiceTelemetrySettings _telemetry; + private readonly TimeSpan? _defaultVisibilityTimeout; + private readonly bool _createIfNotExists; + private readonly Func? _poisonQueueFactory; + private readonly SemaphoreSlim _createLock = new(1, 1); + private volatile bool _queueEnsured; + private volatile bool _poisonQueueEnsured; + private QueueClient? _poisonQueue; + + public StorageQueue( + QueueClient client, + IQueueMessageSerializer serializer, + ILoggerFactory loggerFactory, + QueueServiceTelemetrySettings telemetry, + TimeSpan? defaultVisibilityTimeout, + bool createIfNotExists, + Func? poisonQueueFactory) + { + _client = client; + _serializer = serializer; + _logger = loggerFactory.CreateLogger(); + _telemetry = telemetry; + _defaultVisibilityTimeout = defaultVisibilityTimeout; + _createIfNotExists = createIfNotExists; + _poisonQueueFactory = poisonQueueFactory; + } + + public string QueueName => _client.Name; + + // ------------------------------------------------------------------------------------------------------------ + // Send + // ------------------------------------------------------------------------------------------------------------ + + public async ValueTask SendAsync(T message, SendOptions? options = null, CancellationToken cancellationToken = default) + { + using Activity? activity = _telemetry.CreateNewActivityOnMessageSend + ? QueueServiceDiagnostics.Activities.StartSendingMessageActivity(QueueTags()) + : null; + + try + { + await EnsureQueueExistsAsync(cancellationToken); + + BinaryData body = _serializer.Serialize(message); + var timeToLive = options?.TimeToLive; + // .NET's infinite sentinel is -1 ms; the service requires -1 whole second. + if (timeToLive == Timeout.InfiniteTimeSpan) timeToLive = TimeSpan.FromSeconds(-1); + SendReceipt receipt = await _client.SendMessageAsync(body, options?.VisibilityTimeout, timeToLive, cancellationToken); + + var tags = QueueTags(); + QueueServiceDiagnostics.Metrics.MessagesSent.Add(1, tags); + QueueServiceDiagnostics.Metrics.MessageSize.Record(body.ToMemory().Length, tags); + + return receipt; + } + catch (Exception e) + { + _logger.LogSendFailed(QueueName, e); + activity?.AddException(e); + throw; + } + } + + // ------------------------------------------------------------------------------------------------------------ + // Receive / peek + // ------------------------------------------------------------------------------------------------------------ + + public async ValueTask>> ReceiveAsync(int maxMessages = 1, TimeSpan? visibilityTimeout = null, CancellationToken cancellationToken = default) + { + var messages = await ReceiveUndeserializedAsync(maxMessages, visibilityTimeout, cancellationToken); + foreach (var message in messages) Deserialize(message); + return messages; + } + + // IOwnedReceiver: the processor's path. The message is handed over with its raw body so renewal can be armed + // before deserialization runs; see IOwnedReceiver. + async ValueTask?> IOwnedReceiver.ReceiveUndeserializedAsync(TimeSpan visibilityTimeout, CancellationToken cancellationToken) + { + var messages = await ReceiveUndeserializedAsync(1, visibilityTimeout, cancellationToken); + return messages.Count == 0 ? null : messages[0]; + } + + public void Deserialize(QueueMessage message) + { + if (message.Error is not null) return; // a decoding failure; there is nothing to deserialize + var (body, error) = TryDeserialize(message.RawBody); + message.SetBody(body, error); + } + + private async ValueTask>> ReceiveUndeserializedAsync(int maxMessages, TimeSpan? visibilityTimeout, CancellationToken cancellationToken) + { + ValidateMaxMessages(maxMessages); + + var tags = QueueTags(); + tags.Add(QueueServiceDiagnostics.Names.QueueMessageCount, maxMessages); + using Activity? activity = _telemetry.CreateNewActivityOnMessageRetrieval + ? QueueServiceDiagnostics.Activities.StartReadingQueueActivity(tags) + : null; + + try + { + await EnsureQueueExistsAsync(cancellationToken); + + // Messages the SDK could not Base64-decode are raised as events during the call; collect them so they + // come back as messages with Error set instead of being dropped from the batch. + SdkQueueMessage[] received; + var undecodable = DecodingFailures.Begin(); + try + { + received = await _client.ReceiveMessagesAsync(maxMessages, visibilityTimeout ?? _defaultVisibilityTimeout, cancellationToken); + } + finally + { + DecodingFailures.End(); + } + + var count = received.Length + undecodable.Count; + var messages = new List>(count); + if (count == 0) return messages; + + _logger.LogMessagesReceived(QueueName, count); + activity?.AddEvent(new ActivityEvent(QueueServiceDiagnostics.Events.QueueMessageReceived)); + QueueServiceDiagnostics.Metrics.MessagesReceived.Add(count, QueueTags()); + + foreach (var m in received) + { + var raw = m.Body ?? BinaryData.FromString(m.MessageText ?? string.Empty); + messages.Add(new QueueMessage(Info(m), raw, default, null, this)); + } + + foreach (var failure in undecodable) + { + if (failure.ReceivedMessage is not { } m) continue; + _logger.LogDecodingFailed(QueueName, m.MessageId, m.DequeueCount); + var raw = BinaryData.FromString(m.MessageText ?? string.Empty); + var error = new DeserializationException($"Message {m.MessageId} is not valid Base64, so the SDK could not decode it."); + messages.Add(new QueueMessage(Info(m), raw, default, error, this)); + } + + return messages; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception e) + { + _logger.LogReceiveFailed(QueueName, e); + activity?.AddException(e); + throw; + } + } + + public async ValueTask>> PeekAsync(int maxMessages = 1, CancellationToken cancellationToken = default) + { + ValidateMaxMessages(maxMessages); + await EnsureQueueExistsAsync(cancellationToken); + + PeekedMessage[] peeked; + var undecodable = DecodingFailures.Begin(); + try + { + peeked = await _client.PeekMessagesAsync(maxMessages, cancellationToken); + } + finally + { + DecodingFailures.End(); + } + + var messages = new List>(peeked.Length + undecodable.Count); + foreach (var m in peeked) + { + var raw = m.Body ?? BinaryData.FromString(m.MessageText ?? string.Empty); + var (body, error) = TryDeserialize(raw); + messages.Add(new PeekedQueueMessage(m.MessageId, m.DequeueCount, m.InsertedOn, m.ExpiresOn, raw, body, error)); + } + + foreach (var failure in undecodable) + { + if (failure.PeekedMessage is not { } m) continue; + var raw = BinaryData.FromString(m.MessageText ?? string.Empty); + var error = new DeserializationException($"Message {m.MessageId} is not valid Base64, so the SDK could not decode it."); + messages.Add(new PeekedQueueMessage(m.MessageId, m.DequeueCount, m.InsertedOn, m.ExpiresOn, raw, default, error)); + } + + return messages; + } + + private static QueueMessageInfo Info(SdkQueueMessage m) => + new(m.MessageId, m.PopReceipt, m.DequeueCount, m.InsertedOn, m.ExpiresOn, m.NextVisibleOn); + + private (T? Body, DeserializationException? Error) TryDeserialize(BinaryData raw) + { + try + { + return (_serializer.Deserialize(raw), null); + } + catch (DeserializationException e) + { + return (default, e); + } + catch (Exception e) + { + // A custom serializer that throws something else still must not take the receive down. + return (default, new DeserializationException($"Could not deserialize the queue message as {typeof(T).Name}: {e.Message}", e)); + } + } + + private static void ValidateMaxMessages(int maxMessages) + { + if (maxMessages is < 1 or > ServiceMaxMessages) + throw new ArgumentOutOfRangeException(nameof(maxMessages), maxMessages, $"The service allows 1 to {ServiceMaxMessages} messages per receive."); + } + + // ------------------------------------------------------------------------------------------------------------ + // Admin + // ------------------------------------------------------------------------------------------------------------ + + public async ValueTask CreateIfNotExistsAsync(IDictionary? metadata = null, CancellationToken cancellationToken = default) + { + await _client.CreateIfNotExistsAsync(metadata, cancellationToken); + _queueEnsured = true; + } + + public async ValueTask ClearAsync(CancellationToken cancellationToken = default) + { + await EnsureQueueExistsAsync(cancellationToken); + await _client.ClearMessagesAsync(cancellationToken); + } + + public async ValueTask GetPropertiesAsync(CancellationToken cancellationToken = default) + { + await EnsureQueueExistsAsync(cancellationToken); + var response = await _client.GetPropertiesAsync(cancellationToken); + return response.Value; + } + + // ------------------------------------------------------------------------------------------------------------ + // IQueueMessageOperations — what a QueueMessage calls to settle itself + // ------------------------------------------------------------------------------------------------------------ + + async ValueTask IQueueMessageOperations.DeleteAsync(string messageId, string popReceipt, CancellationToken cancellationToken) + { + await _client.DeleteMessageAsync(messageId, popReceipt, cancellationToken); + } + + async ValueTask IQueueMessageOperations.UpdateVisibilityAsync(string messageId, string popReceipt, TimeSpan visibilityTimeout, CancellationToken cancellationToken) + { + // No message text: only the visibility changes. + UpdateReceipt receipt = await _client.UpdateMessageAsync(messageId, popReceipt, (string?)null, visibilityTimeout, cancellationToken); + return new VisibilityReceipt(receipt.PopReceipt, receipt.NextVisibleOn); + } + + async ValueTask IQueueMessageOperations.DeadLetterAsync(string messageId, string popReceipt, long dequeueCount, BinaryData rawBody, string? reason, CancellationToken cancellationToken) + { + if (_poisonQueueFactory is null) + throw new InvalidOperationException($"Queue '{QueueName}' has no poison queue configured, so messages cannot be dead-lettered."); + + var poison = _poisonQueue ??= _poisonQueueFactory(); + + // Raw body unchanged, so a poison message can be moved straight back to replay it. The reason goes to the log; + // storage queue messages have no properties to carry it in. Copy-then-delete is two calls: a lease that expires + // between them can leave a duplicate in the poison queue, which is the at-least-once trade. + if (!_poisonQueueEnsured) + { + await poison.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + _poisonQueueEnsured = true; + } + + try + { + await poison.SendMessageAsync(rawBody, cancellationToken: cancellationToken); + } + catch (RequestFailedException e) when (e.ErrorCode == QueueErrorCode.QueueNotFound) + { + // Someone deleted the poison queue since it was created; recreate it on the next attempt. + _poisonQueueEnsured = false; + throw; + } + await _client.DeleteMessageAsync(messageId, popReceipt, cancellationToken); + + _logger.LogDeadLettered(QueueName, messageId, poison.Name, dequeueCount, reason ?? "(none)"); + } + + // ------------------------------------------------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------------------------------------------------ + + private TagList QueueTags() => new() { new(QueueServiceDiagnostics.Names.QueueName, QueueName) }; + + // Queue creation happens on the first operation, once, asynchronously — never while resolving from DI. + private async ValueTask EnsureQueueExistsAsync(CancellationToken cancellationToken) + { + if (!_createIfNotExists || _queueEnsured) return; + + await _createLock.WaitAsync(cancellationToken); + try + { + if (_queueEnsured) return; + await _client.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + _queueEnsured = true; + } + finally + { + _createLock.Release(); + } + } +} diff --git a/src/AzureStorage.QueueService/Telemetry/ActivityExtensions.cs b/src/AzureStorage.QueueService/Telemetry/ActivityExtensions.cs index 41a45e4..66ca6d3 100644 --- a/src/AzureStorage.QueueService/Telemetry/ActivityExtensions.cs +++ b/src/AzureStorage.QueueService/Telemetry/ActivityExtensions.cs @@ -2,14 +2,13 @@ namespace AzureStorage.QueueService.Telemetry; -public static class ActivityExtensions +internal static class ActivityExtensions { - public static Activity? StartActivityWithTags(this ActivitySource activitySource, string name, - Action tags) - { - var tagList = new TagList(); - tags(tagList); - return activitySource.StartActivity(name, ActivityKind.Internal, - Activity.Current?.Context ?? new ActivityContext(), tagList); - } + /// + /// Starts an activity with the given tags, parented to if there is one. + /// Takes the populated list by value: is a struct, so the earlier callback-based + /// version mutated a copy and every span was emitted without its tags. + /// + public static Activity? StartActivityWithTags(this ActivitySource activitySource, string name, ActivityKind kind, in TagList tags) => + activitySource.StartActivity(name, kind, parentId: null, tags: tags); } diff --git a/src/AzureStorage.QueueService/Telemetry/MetricExtensions.cs b/src/AzureStorage.QueueService/Telemetry/MetricExtensions.cs deleted file mode 100644 index f5b6880..0000000 --- a/src/AzureStorage.QueueService/Telemetry/MetricExtensions.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Diagnostics; -using System.Diagnostics.Metrics; - -namespace AzureStorage.QueueService.Telemetry; - -public static class MetricExtensions -{ - public static async Task ProcessWithMetricsAsync( - this Histogram histogram, - Func processFunc, - TagList tags, - Func? onFailure = null) - { - // More efficient than creating a Stopwatch instance - var start = Stopwatch.GetTimestamp(); - bool success = false; - - try - { - await processFunc(); - success = true; - } - catch (Exception ex) - { - QueueServiceDiagnostics.Metrics.MessageFailures.Add(1, tags); - - if (onFailure is not null) - { - await onFailure(ex); - } - else - { - // Rethrow so the extension method can bubble up the error - throw; - } - } - finally - { - // TagList is a struct, so the tag has to be added before the record call to have any effect. - tags.Add(QueueServiceDiagnostics.Names.QueueProcessSuccess, success); - histogram.Record(Stopwatch.GetElapsedTime(start).TotalSeconds, tags); - } - } -} diff --git a/src/AzureStorage.QueueService/Telemetry/QueueServiceDiagnostics.cs b/src/AzureStorage.QueueService/Telemetry/QueueServiceDiagnostics.cs index 828680e..0c9174d 100644 --- a/src/AzureStorage.QueueService/Telemetry/QueueServiceDiagnostics.cs +++ b/src/AzureStorage.QueueService/Telemetry/QueueServiceDiagnostics.cs @@ -13,17 +13,22 @@ internal class QueueServiceDiagnostics public struct Activities { - public static Activity? StartReadingQueueActivity(Action tags) => ActivitySource.StartActivityWithTags(Names.ReadingMessages, tags); - public static Activity? StartSendingMessageActivity(Action tags) => ActivitySource.StartActivityWithTags(Names.SendingMessage, tags); + public static Activity? StartReadingQueueActivity(in TagList tags) => ActivitySource.StartActivityWithTags(Names.ReadingMessages, ActivityKind.Client, tags); + public static Activity? StartSendingMessageActivity(in TagList tags) => ActivitySource.StartActivityWithTags(Names.SendingMessage, ActivityKind.Producer, tags); + public static Activity? StartProcessingMessageActivity(in TagList tags) => ActivitySource.StartActivityWithTags(Names.ProcessingMessage, ActivityKind.Consumer, tags); } public struct Names { public const string ReadingMessages = "Queue.Read"; public const string SendingMessage = "Queue.Send"; + public const string ProcessingMessage = "Queue.Process"; public const string QueueName = "queue.name"; public const string QueueMessageCount = "queue.message_count"; public const string QueueProcessSuccess = "queue.process_success"; + public const string MessageId = "queue.message_id"; + public const string DequeueCount = "queue.dequeue_count"; + public const string Outcome = "queue.outcome"; } public struct Metrics @@ -57,11 +62,6 @@ public struct Metrics "queue_message_size_bytes", description: "Size of messages sent to the queue." ); - - public static readonly UpDownCounter QueueDepth = Meter.CreateUpDownCounter( - "queue_depth", - description: "Current number of messages in the queue." - ); } // Event names only. An ActivityEvent captures its timestamp when constructed, so a shared static instance @@ -69,6 +69,5 @@ public struct Metrics public struct Events { public const string QueueMessageReceived = "queue.message_received"; - public const string QueueMessageProcessed = "queue.message_processed"; } } diff --git a/src/AzureStorage.QueueService/Telemetry/TagListExtensions.cs b/src/AzureStorage.QueueService/Telemetry/TagListExtensions.cs deleted file mode 100644 index 5886477..0000000 --- a/src/AzureStorage.QueueService/Telemetry/TagListExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Diagnostics; - -namespace AzureStorage.QueueService.Telemetry; - -public static class TagListExtensions -{ - public static TagList ToTagList(this (string Key, object? Value)[] tags) - { - var tagList = new TagList(); - foreach (var (key, value) in tags) - { - tagList.Add(key, value); - } - return tagList; - } -} diff --git a/tests/AzureStorage.QueueClient.IntegrationTests/AzureStorage.QueueClient.IntegrationTests.csproj b/tests/AzureStorage.QueueClient.IntegrationTests/AzureStorage.QueueClient.IntegrationTests.csproj index f03f179..0d55207 100644 --- a/tests/AzureStorage.QueueClient.IntegrationTests/AzureStorage.QueueClient.IntegrationTests.csproj +++ b/tests/AzureStorage.QueueClient.IntegrationTests/AzureStorage.QueueClient.IntegrationTests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable @@ -11,7 +11,7 @@ - + diff --git a/tests/AzureStorage.QueueClient.IntegrationTests/IntegrationTests.cs b/tests/AzureStorage.QueueClient.IntegrationTests/IntegrationTests.cs index 2f550a2..131f5e2 100644 --- a/tests/AzureStorage.QueueClient.IntegrationTests/IntegrationTests.cs +++ b/tests/AzureStorage.QueueClient.IntegrationTests/IntegrationTests.cs @@ -1,17 +1,25 @@ using System.Net.Http.Json; +using System.Text; +using Azure.Storage.Queues; +using AzureStorage.QueueService; using AzureStorage.QueueService.Tests.Server; using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Xunit.Abstractions; namespace AzureStorage.QueueClient.IntegrationTests; public class IntegrationTests : IClassFixture> { + private readonly TestWebApplicationFactory _factory; private readonly HttpClient _httpClient; public IntegrationTests(ITestOutputHelper testOutputHelper, TestWebApplicationFactory factory) { factory.TestOutputHelper = testOutputHelper; + _factory = factory; _httpClient = factory.CreateClient(); } @@ -33,4 +41,159 @@ public async Task SendMessage_SendsAnd_ReturnsSameMessage() personResponse.Should().NotBeNull(); personResponse!.FirstName.Should().Be(person.FirstName); } -} \ No newline at end of file + + [Fact(DisplayName = "The documented infinite TTL sends a message that never expires")] + public async Task Send_Infinite_TimeToLive_Does_Not_Expire() + { + var connectionString = _factory.Services.GetRequiredService()["Storage:ConnectionString"]!; + var queueName = "never-expire-" + Guid.NewGuid().ToString("N"); + var raw = new Azure.Storage.Queues.QueueClient(connectionString, queueName); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAzureStorageQueueClient(queues => queues.AddDefaultClient(settings => + { + settings.ConnectionString = connectionString; + settings.QueueName = queueName; + settings.CreateIfNotExists = true; + })); + using var provider = services.BuildServiceProvider(); + var queue = provider.GetRequiredService().GetQueue(); + + try + { + var receipt = await queue.SendAsync(new Person { FirstName = "persistent" }, + new SendOptions(TimeToLive: Timeout.InfiniteTimeSpan)); + + receipt.ExpirationTime.Year.Should().Be(9999); + (await raw.PeekMessagesAsync(1)).Value.Should().ContainSingle().Which.ExpiresOn.Should().Be(receipt.ExpirationTime); + } + finally + { + await raw.DeleteIfExistsAsync(); + } + } + + [Fact(DisplayName = "S1: a 56-character queue dead-letters to its 63-character default poison queue")] + public async Task LongQueueName_DeadLetters_To_Its_Default_Poison_Queue() + { + var connectionString = _factory.Services.GetRequiredService()["Storage:ConnectionString"]!; + var queue = _factory.Services.GetRequiredService().GetQueue("long"); + queue.QueueName.Should().HaveLength(56); + await queue.CreateIfNotExistsAsync(); + await queue.ClearAsync(); + var poison = new Azure.Storage.Queues.QueueClient(connectionString, queue.QueueName + "-poison"); + poison.Name.Should().HaveLength(63); + await poison.CreateIfNotExistsAsync(); + await poison.ClearMessagesAsync(); + + await queue.SendAsync(new Person { FirstName = "poisoned" }); + var message = (await queue.ReceiveAsync()).Should().ContainSingle().Subject; + + await message.DeadLetterAsync("test"); + + message.Settlement.Should().Be(MessageSettlement.DeadLettered); + (await poison.PeekMessagesAsync(10)).Value.Should().ContainSingle(); + (await new Azure.Storage.Queues.QueueClient(connectionString, queue.QueueName).PeekMessagesAsync(10)).Value.Should().BeEmpty(); + } + + [Theory(DisplayName = "Reused Base64 options preserve default and custom decoding behavior in direct SDK receives and peeks")] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task Shared_Base64_Options_Preserve_Decoding_Behavior(bool peek, bool customHandler) + { + var connectionString = _factory.Services.GetRequiredService()["Storage:ConnectionString"]!; + var queueName = "shared-options-" + Guid.NewGuid().ToString("N"); + var rawBefore = new Azure.Storage.Queues.QueueClient(connectionString, queueName + "-before"); + var rawAfter = new Azure.Storage.Queues.QueueClient(connectionString, queueName + "-after"); + var options = new QueueClientOptions { MessageEncoding = QueueMessageEncoding.Base64 }; + var handled = 0; + if (customHandler) + options.MessageDecodingFailed += _ => + { + handled++; + return Task.CompletedTask; + }; + var before = new Azure.Storage.Queues.QueueClient(connectionString, rawBefore.Name, options); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAzureStorageQueueClient(queues => queues.AddDefaultClient(settings => + { + settings.ConnectionString = connectionString; + settings.QueueName = rawAfter.Name; + settings.ClientOptions = options; + })); + using var provider = services.BuildServiceProvider(); + _ = provider.GetRequiredService().GetQueue(); + var after = new Azure.Storage.Queues.QueueClient(connectionString, rawAfter.Name, options); + + try + { + await rawBefore.CreateIfNotExistsAsync(); + await rawAfter.CreateIfNotExistsAsync(); + await rawBefore.SendMessageAsync("not base64!"); + await rawAfter.SendMessageAsync("not base64!"); + + async Task ReadAsync(Azure.Storage.Queues.QueueClient client) + { + if (peek) return (await client.PeekMessagesAsync(1)).Value.Length; + return (await client.ReceiveMessagesAsync(1, TimeSpan.FromSeconds(1))).Value.Length; + } + + if (customHandler) + { + (await ReadAsync(before)).Should().Be(0); + (await ReadAsync(after)).Should().Be(0); + handled.Should().Be(2, "the caller's existing handler controls both direct SDK clients"); + } + else + { + await ((Func)(() => ReadAsync(before))).Should().ThrowAsync(); + await ((Func)(() => ReadAsync(after))).Should().ThrowAsync( + "library client construction must not silently suppress a direct SDK client's decoding errors"); + } + } + finally + { + await rawBefore.DeleteIfExistsAsync(); + await rawAfter.DeleteIfExistsAsync(); + } + } + + [Fact(DisplayName = "F2: on a Base64 queue, a message that is not Base64 comes back with Error set, the healthy one still arrives, and the bad one can be dead-lettered")] + public async Task Base64Queue_UndecodableMessage_DoesNotBreakTheBatch() + { + // arrange + var connectionString = _factory.Services.GetRequiredService()["Storage:ConnectionString"]!; + var queue = _factory.Services.GetRequiredService().GetQueue("base64"); + await queue.CreateIfNotExistsAsync(); + await queue.ClearAsync(); + var poison = new Azure.Storage.Queues.QueueClient(connectionString, queue.QueueName + "-poison"); // no encoding: reads stored text as-is + await poison.CreateIfNotExistsAsync(); + await poison.ClearMessagesAsync(); + + // A plain client (no encoding) puts literal text on the queue that the Base64 client cannot decode. + var raw = new Azure.Storage.Queues.QueueClient(connectionString, queue.QueueName); + await raw.SendMessageAsync("this is not base64!!"); + await queue.SendAsync(new Person { FirstName = "healthy" }); + + // act + var messages = await queue.ReceiveAsync(maxMessages: 10); + + // assert + messages.Should().HaveCount(2); + var healthy = messages.Single(m => m.Error is null); + var bad = messages.Single(m => m.Error is not null); + healthy.Body!.FirstName.Should().Be("healthy"); + bad.RawBody.ToString().Should().Be("this is not base64!!"); + + await bad.DeadLetterAsync("not base64"); + await healthy.CompleteAsync(); + + var poisoned = (await poison.PeekMessagesAsync(10)).Value.Should().ContainSingle().Subject; + Encoding.UTF8.GetString(Convert.FromBase64String(poisoned.MessageText)).Should().Be("this is not base64!!", "the poison client encodes like the source queue, so the raw text round-trips"); + (await raw.PeekMessagesAsync(10)).Value.Should().BeEmpty("both messages were settled"); + } +} diff --git a/tests/AzureStorage.QueueService.Tests.Server/AzureStorage.QueueService.Tests.Server.csproj b/tests/AzureStorage.QueueService.Tests.Server/AzureStorage.QueueService.Tests.Server.csproj index bf0ea1a..d912a1c 100644 --- a/tests/AzureStorage.QueueService.Tests.Server/AzureStorage.QueueService.Tests.Server.csproj +++ b/tests/AzureStorage.QueueService.Tests.Server/AzureStorage.QueueService.Tests.Server.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable dc332eb3-0b07-491c-8284-eda341d078f5 diff --git a/tests/AzureStorage.QueueService.Tests.Server/Endpoints.cs b/tests/AzureStorage.QueueService.Tests.Server/Endpoints.cs index 5702dc6..4e5d39f 100644 --- a/tests/AzureStorage.QueueService.Tests.Server/Endpoints.cs +++ b/tests/AzureStorage.QueueService.Tests.Server/Endpoints.cs @@ -11,25 +11,16 @@ public static IEndpointRouteBuilder MapEndpoints(this IEndpointRouteBuilder buil return builder; } - public static async ValueTask, BadRequest>> SendMessage(Person input, IQueueClientFactory clientFactory) + // Round-trips a message through the real queue (Azurite in CI) using the 3.0 API: send, receive, complete. + public static async ValueTask, BadRequest>> SendMessage(Person input, IStorageQueue queue) { - var client = clientFactory.GetQueueClient(); - await client.SendMessageAsync(input); - - await Task.Delay(1000); + await queue.SendAsync(input); Person? response = null; - await client.ReceiveMessagesAsync(HandleMessage, HandleException, 10); - - ValueTask HandleMessage(Person? message, IDictionary? metadata) - { - response = message; - return ValueTask.CompletedTask; - } - - ValueTask HandleException(Exception ex, IDictionary? metadata) + foreach (var message in await queue.ReceiveAsync(maxMessages: 10)) { - return ValueTask.CompletedTask; + if (message.Error is null) response = message.Body; + await message.CompleteAsync(); } if (response is not null) @@ -37,4 +28,4 @@ ValueTask HandleException(Exception ex, IDictionary? metadata) return TypedResults.BadRequest(); } -} \ No newline at end of file +} diff --git a/tests/AzureStorage.QueueService.Tests.Server/Program.cs b/tests/AzureStorage.QueueService.Tests.Server/Program.cs index 3640c84..a428a26 100644 --- a/tests/AzureStorage.QueueService.Tests.Server/Program.cs +++ b/tests/AzureStorage.QueueService.Tests.Server/Program.cs @@ -1,14 +1,35 @@ +using Azure.Storage.Queues; using AzureStorage.QueueService; using AzureStorage.QueueService.Tests.Server; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddAzureStorageQueueClient(x => x.AddDefaultClient(settings => +builder.Services.AddAzureStorageQueueClient(x => { - settings.ConnectionString = builder.Configuration["Storage:ConnectionString"]; - settings.QueueName = builder.Configuration["Storage:QueueName"]; - settings.CreateIfNotExists = true; -})); + x.AddDefaultClient(settings => + { + settings.ConnectionString = builder.Configuration["Storage:ConnectionString"]; + settings.QueueName = builder.Configuration["Storage:QueueName"]; + settings.CreateIfNotExists = true; + }); + + // A queue whose name is as long as the default "-poison" suffix allows (56 + 7 = 63 characters). + x.AddClient("long", settings => + { + settings.ConnectionString = builder.Configuration["Storage:ConnectionString"]; + settings.QueueName = "integration-" + new string('x', 44); + settings.CreateIfNotExists = true; + }); + + // A queue that carries Base64-encoded messages, as Azure Functions and older SDKs write them. + x.AddClient("base64", settings => + { + settings.ConnectionString = builder.Configuration["Storage:ConnectionString"]; + settings.QueueName = "integration-base64-queue"; + settings.CreateIfNotExists = true; + settings.ClientOptions = new QueueClientOptions { MessageEncoding = QueueMessageEncoding.Base64 }; + }); +}); var app = builder.Build(); diff --git a/tests/AzureStorage.QueueService.Tests/AzureStorage.QueueService.Tests.csproj b/tests/AzureStorage.QueueService.Tests/AzureStorage.QueueService.Tests.csproj index 23316d8..0f19653 100644 --- a/tests/AzureStorage.QueueService.Tests/AzureStorage.QueueService.Tests.csproj +++ b/tests/AzureStorage.QueueService.Tests/AzureStorage.QueueService.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable @@ -12,6 +12,7 @@ + diff --git a/tests/AzureStorage.QueueService.Tests/AzureStorageQueueServiceTests.cs b/tests/AzureStorage.QueueService.Tests/AzureStorageQueueServiceTests.cs deleted file mode 100644 index 3bfa41b..0000000 --- a/tests/AzureStorage.QueueService.Tests/AzureStorageQueueServiceTests.cs +++ /dev/null @@ -1,217 +0,0 @@ -using Azure; -using Azure.Storage.Queues; -using Azure.Storage.Queues.Models; -using AzureStorage.QueueService.Telemetry; -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Moq; - -namespace AzureStorage.QueueService.Tests; - -public class AzureStorageQueueServiceTests -{ - private static readonly IOptions TelemetrySettings = Options.Create(new QueueServiceTelemetrySettings()); - private static readonly ILoggerFactory LoggerFactory = new ServiceCollection().AddLogging().BuildServiceProvider().GetRequiredService(); - private const string ValidJson = "{\"Property1\":\"test\",\"Property2\":true,\"Property3\":1}"; - - private static Mock QueueClientReturning(params string[] messageIds) - { - var messages = messageIds.Select(id => QueuesModelFactory.QueueMessage(id, $"receipt-{id}", ValidJson, 1)).ToArray(); - var mock = new Mock(); - mock.Setup(x => x.ReceiveMessagesAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(messages, Mock.Of())); - return mock; - } - - private static AzureStorageQueueClient CreateSubject(Mock queueClient, TimeSpan? visibilityTimeout = null) => - new(new JsonQueueMessageConverter(), queueClient.Object, LoggerFactory, TelemetrySettings, visibilityTimeout); - - private static ValueTask Ignore(Exception exception, IDictionary? metadata) => ValueTask.CompletedTask; - - [Fact(DisplayName = "Peek messages returns message collection")] - public async Task Peek_Messages_Returns_Collection() - { - // arrange - var peekedMessages = new[] { QueuesModelFactory.PeekedMessage("1", ValidJson, 1) }; - var mockQueueClient = new Mock(); - mockQueueClient.Setup(x => x.PeekMessagesAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(peekedMessages, Mock.Of())); - - // act - var messages = await CreateSubject(mockQueueClient).PeekMessagesAsync(1); - - // assert - messages.Should().ContainSingle().Which.Property1.Should().Be("test"); - } - - [Fact(DisplayName = "Receive handles every message in the batch and deletes each one")] - public async Task Receive_Handles_And_Deletes_Each_Message() - { - // arrange - var mockQueueClient = QueueClientReturning("1", "2"); - var handled = new List(); - - // act - await CreateSubject(mockQueueClient).ReceiveMessagesAsync( - (message, _) => { handled.Add(message); return ValueTask.CompletedTask; }, - Ignore, - numMessages: 2); - - // assert - handled.Should().HaveCount(2); - mockQueueClient.Verify(x => x.DeleteMessageAsync("1", "receipt-1", It.IsAny()), Times.Once); - mockQueueClient.Verify(x => x.DeleteMessageAsync("2", "receipt-2", It.IsAny()), Times.Once); - } - - [Fact(DisplayName = "A throwing handler leaves its message on the queue and the rest of the batch is still processed")] - public async Task Receive_When_Handler_Throws_Leaves_Message_And_Continues() - { - // arrange - var mockQueueClient = QueueClientReturning("1", "2"); - var reported = new List(); - var calls = 0; - - // act - await CreateSubject(mockQueueClient).ReceiveMessagesAsync( - (_, _) => ++calls == 1 ? throw new InvalidOperationException("Hello from Exception") : ValueTask.CompletedTask, - (exception, _) => { reported.Add(exception); return ValueTask.CompletedTask; }, - numMessages: 2); - - // assert - calls.Should().Be(2); - reported.Should().ContainSingle().Which.Message.Should().Be("Hello from Exception"); - mockQueueClient.Verify(x => x.DeleteMessageAsync("1", It.IsAny(), It.IsAny()), Times.Never); - mockQueueClient.Verify(x => x.DeleteMessageAsync("2", It.IsAny(), It.IsAny()), Times.Once); - } - - [Fact(DisplayName = "A failed receive is reported to the exception handler instead of thrown")] - public async Task Receive_When_Receive_Fails_Reports_Instead_Of_Throwing() - { - // arrange - var mockQueueClient = new Mock(); - mockQueueClient.Setup(x => x.ReceiveMessagesAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(new RequestFailedException(500, "storage is having a bad day")); - var reported = new List(); - - // act - Func act = async () => await CreateSubject(mockQueueClient).ReceiveMessagesAsync( - (_, _) => throw new Exception("handler should not run"), - (exception, _) => { reported.Add(exception); return ValueTask.CompletedTask; }); - - // assert - await act.Should().NotThrowAsync(); - reported.Should().ContainSingle().Which.Should().BeOfType(); - } - - [Fact(DisplayName = "A throwing exception handler does not escape and the rest of the batch is still processed")] - public async Task Receive_When_Exception_Handler_Throws_Does_Not_Escape() - { - // arrange - var mockQueueClient = QueueClientReturning("1", "2"); - var handlerCalls = 0; - - // act - Func act = async () => await CreateSubject(mockQueueClient).ReceiveMessagesAsync( - (_, _) => { handlerCalls++; throw new InvalidOperationException("handler failed"); }, - (_, _) => throw new InvalidOperationException("exception handler failed too"), - numMessages: 2); - - // assert - await act.Should().NotThrowAsync(); - handlerCalls.Should().Be(2); - } - - [Fact(DisplayName = "A delete that fails after the handler succeeded is reported to the exception handler")] - public async Task Receive_When_Delete_Fails_Reports_To_Exception_Handler() - { - // arrange - var mockQueueClient = QueueClientReturning("1"); - mockQueueClient.Setup(x => x.DeleteMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(new RequestFailedException(404, "pop receipt mismatch")); - var reported = new List(); - - // act - await CreateSubject(mockQueueClient).ReceiveMessagesAsync( - (_, _) => ValueTask.CompletedTask, - (exception, _) => { reported.Add(exception); return ValueTask.CompletedTask; }); - - // assert - reported.Should().ContainSingle().Which.Should().BeOfType(); - } - - [Fact(DisplayName = "A message that cannot be deserialized is reported and left on the queue")] - public async Task Receive_When_Conversion_Fails_Reports_And_Leaves_Message() - { - // arrange - var mockQueueClient = new Mock(); - mockQueueClient.Setup(x => x.ReceiveMessagesAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(new[] { QueuesModelFactory.QueueMessage("1", "receipt-1", "this is not json", 3) }, Mock.Of())); - var reported = new List(); - - // act - await CreateSubject(mockQueueClient).ReceiveMessagesAsync( - (_, _) => throw new Exception("handler should not run"), - (exception, _) => { reported.Add(exception); return ValueTask.CompletedTask; }); - - // assert - reported.Should().ContainSingle().Which.Should().BeOfType() - .Which.InnerException.Should().NotBeNull(); - mockQueueClient.Verify(x => x.DeleteMessageAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact(DisplayName = "The caller's own cancellation still propagates")] - public async Task Receive_When_Caller_Cancels_Throws_OperationCanceled() - { - // arrange - using var cts = new CancellationTokenSource(); - cts.Cancel(); - var mockQueueClient = new Mock(); - mockQueueClient.Setup(x => x.ReceiveMessagesAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(new OperationCanceledException(cts.Token)); - var exceptionHandlerCalled = false; - - // act - Func act = async () => await CreateSubject(mockQueueClient).ReceiveMessagesAsync( - (_, _) => ValueTask.CompletedTask, - (_, _) => { exceptionHandlerCalled = true; return ValueTask.CompletedTask; }, - cancellationToken: cts.Token); - - // assert - await act.Should().ThrowAsync(); - exceptionHandlerCalled.Should().BeFalse(); - } - - [Fact(DisplayName = "The configured visibility timeout is passed to the receive call")] - public async Task Receive_Passes_Configured_Visibility_Timeout() - { - // arrange - var mockQueueClient = QueueClientReturning(); - var visibilityTimeout = TimeSpan.FromMinutes(5); - - // act - await CreateSubject(mockQueueClient, visibilityTimeout).ReceiveMessagesAsync((_, _) => ValueTask.CompletedTask, Ignore); - - // assert - mockQueueClient.Verify(x => x.ReceiveMessagesAsync(1, visibilityTimeout, It.IsAny()), Times.Once); - } - - [Fact(DisplayName = "Can send message")] - public async Task Can_Send_Message() - { - // arrange - var testObject = new TestObject { Property1 = "sent", Property2 = true, Property3 = 1 }; - var sendReceipt = QueuesModelFactory.SendReceipt("1", DateTimeOffset.Now, DateTimeOffset.Now, "2", DateTimeOffset.Now); - var mockQueueClient = new Mock(); - mockQueueClient.Setup(x => x.SendMessageAsync(It.IsAny(), null, null, It.IsAny())) - .ReturnsAsync(Response.FromValue(sendReceipt, Mock.Of())); - - // act - var receipt = await CreateSubject(mockQueueClient).SendMessageAsync(testObject); - - // assert - receipt.Should().BeSameAs(sendReceipt); - mockQueueClient.Verify(x => x.SendMessageAsync(It.Is(b => b.ToString().Contains("\"Property1\":\"sent\"")), null, null, It.IsAny()), Times.Once); - } -} diff --git a/tests/AzureStorage.QueueService.Tests/JsonQueueMessageConverterTests.cs b/tests/AzureStorage.QueueService.Tests/JsonQueueMessageConverterTests.cs deleted file mode 100644 index 33c0c0f..0000000 --- a/tests/AzureStorage.QueueService.Tests/JsonQueueMessageConverterTests.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.Text; -using System.Text.Json; -using Azure.Storage.Queues.Models; -using FluentAssertions; - -namespace AzureStorage.QueueService.Tests; - -public class JsonQueueMessageConverterTests -{ - private static TestObject NewTestObject() => new() { Property1 = Guid.NewGuid().ToString(), Property2 = true, Property3 = Random.Shared.Next() }; - - [Fact(DisplayName = "Convert into object")] - public void Convert_To_Object() - { - // arrange - var testObject = NewTestObject(); - var json = JsonSerializer.Serialize(testObject); - var testObjectBytes = Encoding.UTF8.GetBytes(json); - var queueMessage = QueuesModelFactory.QueueMessage("1", "2", new BinaryData(testObjectBytes), 1); - var subject = new JsonQueueMessageConverter(); - - // act - var newTestObject = subject.Convert(queueMessage.Body); - - // assert - testObject.Should().BeEquivalentTo(newTestObject); - } - - [Fact(DisplayName = "Convert empty string throws with the serializer error attached")] - public void Convert_Empty_String_Throws() - { - // arrange - var subject = new JsonQueueMessageConverter(); - - // act/assert - subject.Invoking(x => x.Convert(string.Empty)).Should().Throw() - .WithInnerException(); - } - - [Fact(DisplayName = "Convert Base64-encoded JSON (as written by older SDKs and Azure Functions)")] - public void Convert_Base64_Encoded_Json() - { - // arrange - var testObject = NewTestObject(); - var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(testObject))); - var subject = new JsonQueueMessageConverter(); - - // act - var result = subject.Convert(encoded); - - // assert - result.Should().BeEquivalentTo(testObject); - } - - [Theory(DisplayName = "Short JSON values that are also valid Base64 are read as JSON")] - [InlineData("1234", 1234)] - [InlineData("true", true)] - public void Convert_Short_Json_That_Looks_Like_Base64_Is_Not_Mangled(string input, T expected) - { - // arrange - var subject = new JsonQueueMessageConverter(); - - // act/assert - subject.Convert(input).Should().Be(expected); - } - - [Fact(DisplayName = "Convert from Object to BinaryData and back to Object")] - public void Convert_To_And_From() - { - // arrange - var testObject = NewTestObject(); - - var subject = new JsonQueueMessageConverter(); - - // act - BinaryData binaryData = subject.Convert(testObject); - TestObject? convertedTestObject = subject.Convert(binaryData); - - // assert - testObject.Should().BeEquivalentTo(convertedTestObject); - } - - [Fact(DisplayName = "Custom serializer options are applied when sending as well as receiving")] - public void Convert_Uses_Serializer_Options_When_Sending() - { - // arrange - var testObject = new TestObject { Property1 = "value", Property2 = true, Property3 = 1 }; - var subject = new JsonQueueMessageConverter(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - - // act - var json = subject.Convert(testObject).ToString(); - var roundTripped = subject.Convert(json); - - // assert - json.Should().Contain("\"property1\"").And.NotContain("\"Property1\""); - roundTripped.Should().BeEquivalentTo(testObject); - } -} diff --git a/tests/AzureStorage.QueueService.Tests/LeaseRenewalTests.cs b/tests/AzureStorage.QueueService.Tests/LeaseRenewalTests.cs new file mode 100644 index 0000000..1d3fafb --- /dev/null +++ b/tests/AzureStorage.QueueService.Tests/LeaseRenewalTests.cs @@ -0,0 +1,89 @@ +using System.Runtime.CompilerServices; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; + +namespace AzureStorage.QueueService.Tests; + +public class LeaseRenewalTests +{ + [Theory(DisplayName = "A renewal InvalidOperationException is reported unless the message settled during renewal")] + [InlineData(false)] + [InlineData(true)] + public async Task Renewal_InvalidOperationException_Is_Ignored_Only_After_Settlement(bool settlesDuringRenewal) + { + var time = new FakeTimeProvider(); + var settled = false; + var failure = new InvalidOperationException("The renewal policy failed."); + var attempted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var reported = new List(); + await using var renewal = LeaseRenewal.Start( + renewAsync: () => + { + settled = settlesDuringRenewal; + attempted.TrySetResult(); + throw failure; + }, + isSettled: () => settled, + interval: TimeSpan.FromSeconds(1), + time: time, + stopAll: CancellationToken.None, + onFailed: reported.Add); + + time.Advance(TimeSpan.FromSeconds(1)); + await attempted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await renewal.StopAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10)); + + if (settlesDuringRenewal) + reported.Should().BeEmpty("settlement can race with the renewal attempt"); + else + reported.Should().ContainSingle().Which.Should().BeSameAs(failure); + } + + [Fact(DisplayName = "R1: a disposed renewal releases its registration on the processor-wide token; an undisposed one would not")] + public async Task Disposed_Renewal_Is_Not_Retained_By_The_Parent_Token() + { + // The parent outlives every delivery, so a linked source that stays registered on it is a leak per message. + using var parent = new CancellationTokenSource(); + + var disposed = await StartAndFinishAsync(parent.Token, dispose: true); + var leaked = await StartAndFinishAsync(parent.Token, dispose: false); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + disposed.TryGetTarget(out _).Should().BeFalse("DisposeAsync unregisters the linked source from the parent"); + leaked.TryGetTarget(out _).Should().BeTrue("the control case shows the parent really does retain an undisposed linked source"); + } + + // Separate, non-inlined, so no local keeps the renewal or its source alive once the method returns. + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task> StartAndFinishAsync(CancellationToken parent, bool dispose) + { + var renewal = LeaseRenewal.Start( + renewAsync: () => ValueTask.CompletedTask, + isSettled: () => true, + interval: TimeSpan.FromMinutes(1), + time: new FakeTimeProvider(), + stopAll: parent, + onFailed: _ => { }); + var weak = new WeakReference(renewal.LinkedSource); + + if (dispose) await renewal.DisposeAsync(); + else await renewal.StopAsync(); + + return weak; + } + + [Fact(DisplayName = "Stop and dispose are safe to call repeatedly")] + public async Task Stop_And_Dispose_Are_Idempotent() + { + var renewal = LeaseRenewal.Start(() => ValueTask.CompletedTask, () => true, TimeSpan.FromMinutes(1), new FakeTimeProvider(), CancellationToken.None, _ => { }); + + await renewal.StopAsync(); + await renewal.StopAsync(); + await renewal.DisposeAsync(); + await renewal.DisposeAsync(); + await renewal.StopAsync(); + } +} diff --git a/tests/AzureStorage.QueueService.Tests/QueueClientBuilderTests.cs b/tests/AzureStorage.QueueService.Tests/QueueClientBuilderTests.cs index b681644..df2a4e7 100644 --- a/tests/AzureStorage.QueueService.Tests/QueueClientBuilderTests.cs +++ b/tests/AzureStorage.QueueService.Tests/QueueClientBuilderTests.cs @@ -1,88 +1,178 @@ -using Azure.Identity; +using System.Text.Json; using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; namespace AzureStorage.QueueService.Tests; public class QueueClientBuilderTests { - private const string Description = "Queue client 'test'"; + private sealed class OrderHandler : IQueueMessageHandler + { + public ValueTask HandleAsync(QueueMessage message, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } [Fact] - public void CreateClient_WithEndpointAndCredential_ReturnsQueueClient() + public void AddClient_ReturnsBuilderForThatClient() { - // arrange - var settings = new QueueClientSettings() - { - TokenCredential = new DefaultAzureCredential(), - EndpointUri = new Uri("https://fake.com/queue1"), - }; - var subject = new QueueClientBuilder(); + var services = new ServiceCollection(); + IQueueClientBuilder? builder = null; - // act - var client = subject.CreateClient(Description, settings); + services.AddAzureStorageQueueClient(x => builder = x.AddClient("orders", y => y.ConnectionString = "UseDevelopmentStorage=true")); - // assert - client.Name.Should().Be("queue1"); + builder!.Name.Should().Be("orders"); + builder.Services.Should().BeSameAs(services); } [Fact] - public void CreateClient_WithConnectionString_ReturnsQueueClient() + public void AddDefaultClient_ReturnsBuilderWithDefaultName() { - // arrange - var settings = new QueueClientSettings() - { - ConnectionString = "UseDevelopmentStorage=true", - QueueName = "queue1", - }; - var subject = new QueueClientBuilder(); + var services = new ServiceCollection(); + IQueueClientBuilder? builder = null; - // act - var client = subject.CreateClient(Description, settings); + services.AddAzureStorageQueueClient(x => builder = x.AddDefaultClient(y => y.ConnectionString = "UseDevelopmentStorage=true")); - // assert - client.Name.Should().Be("queue1"); + builder!.Name.Should().Be(Options.DefaultName); } [Fact] - public void CreateClient_WithoutUri_Throws() + public void UseJsonSerializer_SetsAPerClientSerializer() { - // arrange - var settings = new QueueClientSettings() - { - TokenCredential = new DefaultAzureCredential(), - QueueName = "queue1", - }; - var subject = new QueueClientBuilder(); + var services = new ServiceCollection(); + services.AddAzureStorageQueueClient(x => + x.AddClient("orders", y => y.ConnectionString = "UseDevelopmentStorage=true") + .UseJsonSerializer(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + + var settings = services.BuildServiceProvider().GetRequiredService>().Get("orders"); + + settings.Serializer.Should().NotBeNull(); + settings.Serializer!.Serialize(new TestObject { Property1 = "v" }).ToString().Should().Contain("\"property1\""); + } + + [Fact] + public void Configure_RunsAfterTheRegistrationDelegate() + { + var services = new ServiceCollection(); + services.AddAzureStorageQueueClient(x => + x.AddClient("orders", y => y.QueueName = "first").Configure(y => y.QueueName = "second")); - // act & assert - subject.Invoking(x => x.CreateClient(Description, settings)).Should().Throw().WithMessage($"{Description}*"); + services.BuildServiceProvider().GetRequiredService>().Get("orders").QueueName.Should().Be("second"); } [Fact] - public void CreateClient_WithConnectionStringButNoQueueName_Throws() + public void AddProcessor_RegistersAHostedServicePerCall_AndAScopedHandler() { - // arrange - var settings = new QueueClientSettings() + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAzureStorageQueueClient(x => { - ConnectionString = "UseDevelopmentStorage=true", - }; - var subject = new QueueClientBuilder(); + x.AddClient("orders", y => y.ConnectionString = "UseDevelopmentStorage=true").AddProcessor(); + x.AddClient("returns", y => y.ConnectionString = "UseDevelopmentStorage=true").AddProcessor(o => o.MaxConcurrentMessages = 3); + }); + var provider = services.BuildServiceProvider(); + + provider.GetServices().Should().HaveCount(2).And.AllBeOfType>(); + services.Should().Contain(d => d.ServiceType == typeof(OrderHandler) && d.Lifetime == ServiceLifetime.Scoped); + + var options = provider.GetRequiredService>(); + options.Get($"returns|{typeof(OrderHandler).FullName}").MaxConcurrentMessages.Should().Be(3); + options.Get($"orders|{typeof(OrderHandler).FullName}").MaxConcurrentMessages.Should().Be(1); + } + + [Fact] + public async Task ValidateOnStart_FailsHostStartup_WhenAClientIsNotConfigured() + { + using var host = Host.CreateDefaultBuilder().ConfigureServices(services => + services.AddAzureStorageQueueClient(x => + { + x.AddClient("orders", y => y.QueueName = "orders"); // no connection string or endpoint + x.ValidateOnStart(); + })).Build(); + + await host.Invoking(h => h.StartAsync()).Should().ThrowAsync().WithMessage("*'orders'*"); + } + + [Fact] + public async Task ValidateOnStart_AppliesToClientsRegisteredAfterIt() + { + using var host = Host.CreateDefaultBuilder().ConfigureServices(services => + services.AddAzureStorageQueueClient(x => + { + x.ValidateOnStart(); + x.AddDefaultClient(y => y.QueueName = "orders"); + })).Build(); - // act & assert - subject.Invoking(x => x.CreateClient(Description, settings)).Should().Throw().WithMessage("*QueueName*"); + await host.Invoking(h => h.StartAsync()).Should().ThrowAsync().WithMessage("*default*"); } [Fact] - public void CreateClient_WithNothingConfigured_Throws() + public async Task ValidateOnStart_StillRuns_WhenTheAppRegisteredItsOwnValidator() { - // arrange - var settings = new QueueClientSettings() + using var host = Host.CreateDefaultBuilder().ConfigureServices(services => { - QueueName = "queue1", - }; - var subject = new QueueClientBuilder(); + services.AddOptions("orders").Validate(_ => true); // registers another IValidateOptions + services.AddAzureStorageQueueClient(x => + { + x.AddClient("orders", y => y.QueueName = "orders"); + x.ValidateOnStart(); + }); + }).Build(); + + await host.Invoking(h => h.StartAsync()).Should().ThrowAsync().WithMessage("*'orders'*"); + } + + [Fact] + public async Task ValidateOnStart_FailsHostStartup_WhenThePoisonQueueNameIsInvalid() + { + using var host = Host.CreateDefaultBuilder().ConfigureServices(services => + services.AddAzureStorageQueueClient(x => + { + x.AddClient("orders", y => { y.ConnectionString = "UseDevelopmentStorage=true"; y.QueueName = "orders"; y.PoisonQueueName = "Bad_Name"; }); + x.ValidateOnStart(); + })).Build(); + + await host.Invoking(h => h.StartAsync()).Should().ThrowAsync().WithMessage("*PoisonQueueName*"); + } + + [Fact] + public async Task ValidateOnStart_FailsHostStartup_WhenTheDerivedPoisonQueueNameIsTooLong() + { + // R6: a 60-character queue name is valid, but "-poison" is 67; a send-only host must find out at startup. + using var host = Host.CreateDefaultBuilder().ConfigureServices(services => + services.AddAzureStorageQueueClient(x => + { + x.AddClient("orders", y => { y.ConnectionString = "UseDevelopmentStorage=true"; y.QueueName = new string('a', 60); }); + x.ValidateOnStart(); + })).Build(); + + await host.Invoking(h => h.StartAsync()).Should().ThrowAsync().WithMessage("*poison*").WithMessage("*PoisonQueueName*"); + } + + [Fact] + public async Task ValidateOnStart_FailsHostStartup_WhenThePoisonQueueIsTheSourceQueue() + { + using var host = Host.CreateDefaultBuilder().ConfigureServices(services => + services.AddAzureStorageQueueClient(x => + { + x.AddDefaultClient(y => { y.EndpointUri = new Uri("https://account.queue.core.windows.net/orders"); y.TokenCredential = new Azure.Identity.DefaultAzureCredential(); y.PoisonQueueName = "orders"; }); + x.ValidateOnStart(); + })).Build(); + + await host.Invoking(h => h.StartAsync()).Should().ThrowAsync().WithMessage("*equal to its own queue name*"); + } + + [Fact] + public async Task ValidateOnStart_PassesForAConfiguredClient() + { + using var host = Host.CreateDefaultBuilder().ConfigureServices(services => + services.AddAzureStorageQueueClient(x => + { + x.AddClient("orders", y => { y.ConnectionString = "UseDevelopmentStorage=true"; y.QueueName = "orders"; }); + x.ValidateOnStart(); + })).Build(); - // act & assert - subject.Invoking(x => x.CreateClient(Description, settings)).Should().Throw(); + await host.StartAsync(); + await host.StopAsync(); } } diff --git a/tests/AzureStorage.QueueService.Tests/QueueClientFactoryTests.cs b/tests/AzureStorage.QueueService.Tests/QueueClientFactoryTests.cs index b370332..6e6eded 100644 --- a/tests/AzureStorage.QueueService.Tests/QueueClientFactoryTests.cs +++ b/tests/AzureStorage.QueueService.Tests/QueueClientFactoryTests.cs @@ -1,4 +1,5 @@ -using Azure.Storage.Queues; +using Azure.Identity; +using Azure.Storage.Queues; using FluentAssertions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -12,77 +13,71 @@ public class QueueClientFactoryTests /// /// A service collection whose SDK client construction is stubbed out so no settings validation or network - /// access happens. Every call to the builder is recorded in created. + /// access happens. Every call to the SDK factory is recorded in created. /// private static (IServiceCollection Services, List<(string Description, QueueClientSettings Settings)> Created) CreateServices() { var created = new List<(string, QueueClientSettings)>(); - var builder = new Mock(); - builder.Setup(x => x.CreateClient(It.IsAny(), It.IsAny())) + var sdkClients = new Mock(); + sdkClients.Setup(x => x.CreateClient(It.IsAny(), It.IsAny())) .Returns((string description, QueueClientSettings settings) => { created.Add((description, settings)); - return new Mock().Object; + var client = new Mock(); + // The SDK derives the name from the last URI segment, ignoring a trailing slash. + client.Setup(x => x.Name).Returns(settings.QueueName ?? settings.EndpointUri?.AbsolutePath.TrimEnd('/').Split('/').Last() ?? "queue"); + return client.Object; }); var services = new ServiceCollection(); services.AddLogging(); - services.AddSingleton(builder.Object); // registered first so the library's TryAdd keeps it + services.AddSingleton(sdkClients.Object); // registered first so the library's TryAdd keeps it return (services, created); } + private static IQueueClientFactory Factory(IServiceCollection services) => + services.BuildServiceProvider().GetRequiredService(); + + private static ValueTask DeadLetter(IStorageQueue queue) => + ((IQueueMessageOperations)queue).DeadLetterAsync("1", "r", 5, BinaryData.FromString("{}"), null, CancellationToken.None); + [Fact] - public void GetNamedClient_ReturnsSameClientEachTime() + public void GetQueue_ReturnsSameInstanceEachTime() { - // arrange var (services, created) = CreateServices(); services.AddAzureStorageQueueClient(x => x.AddClient("foo", y => { y.ConnectionString = ConnectionString; y.QueueName = "foo-queue"; })); - var factory = services.BuildServiceProvider().GetRequiredService(); + var factory = Factory(services); - // act - var client1 = factory.GetQueueClient("foo"); - var client2 = factory.GetQueueClient("foo"); - - // assert - client1.Should().BeSameAs(client2); + factory.GetQueue("foo").Should().BeSameAs(factory.GetQueue("foo")); + factory.GetQueue("foo").QueueName.Should().Be("foo-queue"); created.Should().ContainSingle().Which.Settings.QueueName.Should().Be("foo-queue"); } [Fact] public void RegisterMultipleNamedClients_EachGetsItsOwnSettings() { - // arrange var (services, created) = CreateServices(); services.AddAzureStorageQueueClient(x => { x.AddClient("foo", y => { y.ConnectionString = ConnectionString; y.QueueName = "foo-queue"; }); x.AddClient("bar", y => { y.ConnectionString = ConnectionString; y.QueueName = "bar-queue"; }); }); - var factory = services.BuildServiceProvider().GetRequiredService(); - - // act - var foo = factory.GetQueueClient("foo"); - var bar = factory.GetQueueClient("bar"); + var factory = Factory(services); - // assert - foo.Should().NotBeSameAs(bar); + factory.GetQueue("foo").Should().NotBeSameAs(factory.GetQueue("bar")); created.Select(c => c.Settings.QueueName).Should().BeEquivalentTo("foo-queue", "bar-queue"); } [Fact] public void AddClientTwiceForSameName_ComposesConfiguration() { - // arrange var (services, created) = CreateServices(); services.AddAzureStorageQueueClient(x => x.AddClient("foo", y => y.ConnectionString = ConnectionString)); services.AddAzureStorageQueueClient(x => x.AddClient("foo", y => y.QueueName = "foo-queue")); - var factory = services.BuildServiceProvider().GetRequiredService(); - // act - factory.GetQueueClient("foo"); + Factory(services).GetQueue("foo"); - // assert var settings = created.Should().ContainSingle().Which.Settings; settings.ConnectionString.Should().Be(ConnectionString); settings.QueueName.Should().Be("foo-queue"); @@ -91,17 +86,14 @@ public void AddClientTwiceForSameName_ComposesConfiguration() [Fact] public void SeparateServiceCollections_DoNotShareSettings() { - // arrange var (servicesA, createdA) = CreateServices(); var (servicesB, createdB) = CreateServices(); servicesA.AddAzureStorageQueueClient(x => x.AddDefaultClient(y => { y.ConnectionString = ConnectionString; y.QueueName = "queue-a"; })); servicesB.AddAzureStorageQueueClient(x => x.AddDefaultClient(y => { y.ConnectionString = ConnectionString; y.QueueName = "queue-b"; })); - // act - servicesA.BuildServiceProvider().GetRequiredService().GetQueueClient(); - servicesB.BuildServiceProvider().GetRequiredService().GetQueueClient(); + Factory(servicesA).GetQueue(); + Factory(servicesB).GetQueue(); - // assert createdA.Should().ContainSingle().Which.Settings.QueueName.Should().Be("queue-a"); createdB.Should().ContainSingle().Which.Settings.QueueName.Should().Be("queue-b"); } @@ -109,32 +101,25 @@ public void SeparateServiceCollections_DoNotShareSettings() [Fact] public void GetUnregisteredClient_ThrowsNamingTheRegisteredClients() { - // arrange var (services, _) = CreateServices(); services.AddAzureStorageQueueClient(x => x.AddClient("foo", y => y.ConnectionString = ConnectionString)); - var factory = services.BuildServiceProvider().GetRequiredService(); - // act/assert - factory.Invoking(x => x.GetQueueClient("bar")).Should().Throw() + Factory(services).Invoking(x => x.GetQueue("bar")).Should().Throw() .WithMessage("*'bar'*").WithMessage("*'foo'*"); } [Fact] public void GetDefaultClient_WhenNoneRegistered_Throws() { - // arrange var (services, _) = CreateServices(); services.AddAzureStorageQueueClient(x => x.AddClient("foo", y => y.ConnectionString = ConnectionString)); - var factory = services.BuildServiceProvider().GetRequiredService(); - // act/assert - factory.Invoking(x => x.GetQueueClient()).Should().Throw().WithMessage("*AddDefaultClient*"); + Factory(services).Invoking(x => x.GetQueue()).Should().Throw().WithMessage("*AddDefaultClient*"); } [Fact] - public void Clients_CanBeInjectedDirectly() + public void Queues_CanBeInjectedDirectly() { - // arrange var (services, _) = CreateServices(); services.AddAzureStorageQueueClient(x => { @@ -144,49 +129,137 @@ public void Clients_CanBeInjectedDirectly() var provider = services.BuildServiceProvider(); var factory = provider.GetRequiredService(); - // act/assert - provider.GetRequiredKeyedService("foo").Should().BeSameAs(factory.GetQueueClient("foo")); - provider.GetRequiredKeyedService("foo").Should().BeSameAs(factory.GetQueueClient("foo")); - provider.GetRequiredService().Should().BeSameAs(factory.GetQueueClient()); - provider.GetRequiredService().Should().BeSameAs(factory.GetQueueClient()); + var foo = factory.GetQueue("foo"); + provider.GetRequiredKeyedService("foo").Should().BeSameAs(foo); + provider.GetRequiredKeyedService("foo").Should().BeSameAs(foo); + provider.GetRequiredKeyedService("foo").Should().BeSameAs(foo); + + var @default = factory.GetQueue(); + provider.GetRequiredService().Should().BeSameAs(@default); + provider.GetRequiredService().Should().BeSameAs(@default); + provider.GetRequiredService().Should().BeSameAs(@default); } [Fact] public void AddClient_FromConfigurationSection_BindsSettings() { - // arrange var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Queues:Foo:ConnectionString"] = ConnectionString, ["Queues:Foo:QueueName"] = "foo-queue", ["Queues:Foo:VisibilityTimeout"] = "00:05:00", + ["Queues:Foo:PoisonQueueName"] = "foo-dead", }).Build(); var (services, created) = CreateServices(); services.AddAzureStorageQueueClient(x => x.AddClient("foo", configuration.GetSection("Queues:Foo"))); - var factory = services.BuildServiceProvider().GetRequiredService(); - // act - factory.GetQueueClient("foo"); + Factory(services).GetQueue("foo"); - // assert var settings = created.Should().ContainSingle().Which.Settings; settings.QueueName.Should().Be("foo-queue"); settings.VisibilityTimeout.Should().Be(TimeSpan.FromMinutes(5)); + settings.PoisonQueueName.Should().Be("foo-dead"); + } + + // --------------------------------------------------------------------------------------------------------- + // Poison queue (F6) + // --------------------------------------------------------------------------------------------------------- + + [Fact] + public async Task PoisonQueue_UsesTheSameAccount_WithConnectionString() + { + var (services, created) = CreateServices(); + services.AddAzureStorageQueueClient(x => x.AddClient("orders", y => { y.ConnectionString = ConnectionString; y.QueueName = "orders"; })); + + await DeadLetter(Factory(services).GetQueue("orders")); + + created.Should().HaveCount(2); + created[1].Description.Should().Contain("poison"); + created[1].Settings.ConnectionString.Should().Be(ConnectionString); + created[1].Settings.QueueName.Should().Be("orders-poison"); + } + + [Theory] + [InlineData("https://account.queue.core.windows.net/orders")] + [InlineData("https://account.queue.core.windows.net/orders/")] + public async Task PoisonQueue_UsesTheSameAccount_WithEndpointUri(string endpoint) + { + var (services, created) = CreateServices(); + var credential = new DefaultAzureCredential(); + services.AddAzureStorageQueueClient(x => x.AddClient("orders", y => + { + y.EndpointUri = new Uri(endpoint); + y.TokenCredential = credential; + })); + + await DeadLetter(Factory(services).GetQueue("orders")); + + created[1].Settings.EndpointUri.Should().Be(new Uri("https://account.queue.core.windows.net/orders-poison")); + created[1].Settings.TokenCredential.Should().BeSameAs(credential); + } + + [Fact] + public void SiblingQueueUri_KeepsTheQueryString() + { + QueueClientFactory.SiblingQueueUri(new Uri("https://account.queue.core.windows.net/orders?sv=2024&sig=abc"), "orders-poison") + .Should().Be(new Uri("https://account.queue.core.windows.net/orders-poison?sv=2024&sig=abc")); + } + + [Fact] + public async Task PoisonQueue_ExplicitName_IsUsed() + { + var (services, created) = CreateServices(); + services.AddAzureStorageQueueClient(x => x.AddClient("orders", y => { y.ConnectionString = ConnectionString; y.QueueName = "orders"; y.PoisonQueueName = "dead-letters"; })); + + await DeadLetter(Factory(services).GetQueue("orders")); + + created[1].Settings.QueueName.Should().Be("dead-letters"); + } + + [Fact] + public void PoisonQueue_DerivedNameTooLong_FailsClearly() + { + var longName = new string('a', 57); // valid itself; "-poison" makes it 64 + var (services, _) = CreateServices(); + services.AddAzureStorageQueueClient(x => x.AddClient("orders", y => { y.ConnectionString = ConnectionString; y.QueueName = longName; })); + + Factory(services).Invoking(f => f.GetQueue("orders")).Should().Throw() + .WithMessage("*poison*").WithMessage("*PoisonQueueName*"); + } + + [Fact] + public void PoisonQueue_SameAsSource_IsRejected_WithConnectionString() + { + var (services, _) = CreateServices(); + services.AddAzureStorageQueueClient(x => x.AddClient("orders", y => { y.ConnectionString = ConnectionString; y.QueueName = "orders"; y.PoisonQueueName = "orders"; })); + + Factory(services).Invoking(f => f.GetQueue("orders")).Should().Throw().WithMessage("*equal to its own queue name*"); + } + + [Fact] + public void PoisonQueue_SameAsSource_IsRejected_WithEndpointUri() + { + var (services, _) = CreateServices(); + services.AddAzureStorageQueueClient(x => x.AddClient("orders", y => + { + y.EndpointUri = new Uri("https://account.queue.core.windows.net/orders/"); + y.TokenCredential = new DefaultAzureCredential(); + y.PoisonQueueName = "orders"; + })); + + Factory(services).Invoking(f => f.GetQueue("orders")).Should().Throw().WithMessage("*equal to its own queue name*"); } [Fact] public void ConcurrentFirstCalls_CreateOneClient() { - // arrange var (services, created) = CreateServices(); - services.AddAzureStorageQueueClient(x => x.AddClient("foo", y => y.ConnectionString = ConnectionString)); - var factory = services.BuildServiceProvider().GetRequiredService(); + services.AddAzureStorageQueueClient(x => x.AddClient("foo", y => { y.ConnectionString = ConnectionString; y.QueueName = "foo-queue"; })); + var factory = Factory(services); - // act - var clients = Enumerable.Range(0, 32).AsParallel().Select(_ => factory.GetQueueClient("foo")).ToList(); + var queues = Enumerable.Range(0, 32).AsParallel().Select(_ => factory.GetQueue("foo")).ToList(); - // assert created.Should().ContainSingle(); - clients.Distinct().Should().ContainSingle(); + queues.Distinct().Should().ContainSingle(); } } diff --git a/tests/AzureStorage.QueueService.Tests/QueueClientSettingsTests.cs b/tests/AzureStorage.QueueService.Tests/QueueClientSettingsTests.cs new file mode 100644 index 0000000..0eb5135 --- /dev/null +++ b/tests/AzureStorage.QueueService.Tests/QueueClientSettingsTests.cs @@ -0,0 +1,128 @@ +using Azure.Identity; +using FluentAssertions; + +namespace AzureStorage.QueueService.Tests; + +/// +/// Validation by responsibility: connection checks apply to every SDK client the library builds; source-queue +/// policy (poison destination, receive timing) applies to source queues only. Uses the real SDK factory where the +/// reviewer's regressions showed a mock would hide the problem. +/// +public class QueueClientSettingsTests +{ + private const string ConnectionString = "UseDevelopmentStorage=true"; + private const string Account = "https://account.queue.core.windows.net/"; + + // --------------------------------------------------------------------------------------------------------- + // S1: a poison destination is a destination, not a source with a policy of its own + // --------------------------------------------------------------------------------------------------------- + + [Theory(DisplayName = "S1: the poison client can be built for every default-named source the source validation accepts")] + [InlineData(49)] + [InlineData(50)] + [InlineData(56)] + public void Poison_Client_Builds_For_Default_Names(int sourceLength) + { + var source = new QueueClientSettings { ConnectionString = ConnectionString, QueueName = new string('a', sourceLength) }; + source.Validate().Should().BeNull(); + + var poison = new SdkQueueClientFactory().CreateClient("poison", QueueClientFactory.ForSiblingQueue(source, source.EffectivePoisonQueueName!)); + + poison.Name.Should().HaveLength(sourceLength + "-poison".Length); + } + + [Theory(DisplayName = "S1: an explicit poison name up to the 63-character limit builds a real poison client")] + [InlineData(57)] + [InlineData(63)] + public void Poison_Client_Builds_For_Long_Explicit_Names(int poisonLength) + { + var source = new QueueClientSettings { ConnectionString = ConnectionString, QueueName = "orders", PoisonQueueName = new string('p', poisonLength) }; + source.Validate().Should().BeNull(); + + var poison = new SdkQueueClientFactory().CreateClient("poison", QueueClientFactory.ForSiblingQueue(source, source.EffectivePoisonQueueName!)); + + poison.Name.Should().HaveLength(poisonLength); + } + + [Fact(DisplayName = "S1: the same holds for endpoint authentication")] + public void Poison_Client_Builds_For_Endpoint_Registration() + { + var source = new QueueClientSettings { EndpointUri = new Uri(Account + new string('a', 56)), TokenCredential = new DefaultAzureCredential() }; + source.Validate().Should().BeNull(); + + var poison = new SdkQueueClientFactory().CreateClient("poison", QueueClientFactory.ForSiblingQueue(source, source.EffectivePoisonQueueName!)); + + poison.Name.Should().HaveLength(63); + } + + // --------------------------------------------------------------------------------------------------------- + // S2: the effective queue follows the same precedence as SDK construction + // --------------------------------------------------------------------------------------------------------- + + [Fact(DisplayName = "S2: with endpoint authentication the queue comes from the URI, and a conflicting QueueName is rejected")] + public void Endpoint_With_Conflicting_QueueName_Is_Rejected() + { + var settings = new QueueClientSettings + { + QueueName = "configured-alias", + EndpointUri = new Uri(Account + "orders"), + TokenCredential = new DefaultAzureCredential(), + PoisonQueueName = "orders", // the self-poison loop the alias would have hidden + }; + + settings.EffectiveQueueName.Should().Be("orders"); + settings.ValidateConnection().Should().Contain("points at queue 'orders'"); + settings.Validate().Should().Contain("points at queue 'orders'"); + } + + [Fact(DisplayName = "S2: with endpoint authentication an agreeing QueueName is allowed")] + public void Endpoint_With_Agreeing_QueueName_Is_Allowed() + { + var settings = new QueueClientSettings { QueueName = "orders", EndpointUri = new Uri(Account + "orders/"), TokenCredential = new DefaultAzureCredential() }; + + settings.Validate().Should().BeNull(); + settings.EffectivePoisonQueueName.Should().Be("orders-poison"); + } + + [Fact(DisplayName = "An empty PoisonQueueName (a blank placeholder in configuration) falls back to the default")] + public void Empty_PoisonQueueName_Falls_Back_To_Default() + { + var settings = new QueueClientSettings { ConnectionString = ConnectionString, QueueName = "orders", PoisonQueueName = "" }; + + settings.Validate().Should().BeNull(); + settings.EffectivePoisonQueueName.Should().Be("orders-poison"); + } + + [Fact(DisplayName = "S2: with a connection string the queue is QueueName, and an EndpointUri left over is ignored")] + public void ConnectionString_Takes_Precedence_Over_Endpoint() + { + var settings = new QueueClientSettings { ConnectionString = ConnectionString, QueueName = "orders", EndpointUri = new Uri(Account + "other") }; + + settings.EffectiveQueueName.Should().Be("orders"); + settings.Validate().Should().BeNull(); + } + + [Fact(DisplayName = "S2: an endpoint whose path has no queue is rejected")] + public void Endpoint_Without_Queue_Is_Rejected() + { + var settings = new QueueClientSettings { EndpointUri = new Uri(Account), TokenCredential = new DefaultAzureCredential() }; + + settings.ValidateConnection().Should().Contain("no queue name"); + } + + // --------------------------------------------------------------------------------------------------------- + // S3: receive timing + // --------------------------------------------------------------------------------------------------------- + + [Theory(DisplayName = "S3: a client VisibilityTimeout outside 1 second to 7 days is rejected; the boundaries are accepted")] + [InlineData(0.5, false)] + [InlineData(1, true)] + [InlineData(7 * 24 * 3600, true)] + [InlineData(7 * 24 * 3600 + 1, false)] + public void Client_VisibilityTimeout_Is_Bounded(double seconds, bool valid) + { + var settings = new QueueClientSettings { ConnectionString = ConnectionString, QueueName = "orders", VisibilityTimeout = TimeSpan.FromSeconds(seconds) }; + + (settings.Validate() is null).Should().Be(valid); + } +} diff --git a/tests/AzureStorage.QueueService.Tests/QueueProcessorHostTests.cs b/tests/AzureStorage.QueueService.Tests/QueueProcessorHostTests.cs new file mode 100644 index 0000000..6bd4685 --- /dev/null +++ b/tests/AzureStorage.QueueService.Tests/QueueProcessorHostTests.cs @@ -0,0 +1,93 @@ +using Azure.Storage.Queues.Models; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Time.Testing; +using Moq; + +namespace AzureStorage.QueueService.Tests; + +/// +/// The processor's lifecycle through a real Generic Host, not direct calls: when workers start, and what happens +/// when startup fails after the processor's own start-up steps have run. +/// +public class QueueProcessorHostTests +{ + private static readonly TimeSpan SafetyNet = TimeSpan.FromSeconds(10); + + private sealed class CountingQueue : IStorageQueue + { + private int _receives; + public int Receives => Volatile.Read(ref _receives); + public TaskCompletionSource FirstReceive { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public string QueueName => "orders"; + + public ValueTask>> ReceiveAsync(int maxMessages = 1, TimeSpan? visibilityTimeout = null, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _receives); + FirstReceive.TrySetResult(); + return new(Array.Empty>()); + } + + public ValueTask>> PeekAsync(int maxMessages = 1, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public ValueTask SendAsync(T message, SendOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public ValueTask CreateIfNotExistsAsync(IDictionary? metadata = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public ValueTask ClearAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public ValueTask GetPropertiesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + private sealed class NoopHandler : IQueueMessageHandler + { + public ValueTask HandleAsync(QueueMessage message, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + + /// A service registered after the processor whose own StartedAsync fails — startup has already run the processor's steps by then. + private sealed class FailsAfterStarted : IHostedService, IHostedLifecycleService + { + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StartedAsync(CancellationToken cancellationToken) => throw new InvalidOperationException("a later service failed to start"); + public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + private static IHost BuildHost(CountingQueue queue, Action? more = null) => + Host.CreateDefaultBuilder().ConfigureServices(services => + { + var factory = new Mock(); + factory.Setup(x => x.GetQueue("orders")).Returns(queue); + services.AddSingleton(factory.Object); // the library's TryAdd keeps this one + services.AddSingleton(new FakeTimeProvider()); // so an empty queue never waits on real time + services.AddAzureStorageQueueClient(x => + x.AddClient("orders", y => { y.ConnectionString = "UseDevelopmentStorage=true"; y.QueueName = "orders"; }) + .AddProcessor()); + more?.Invoke(services); + }).Build(); + + [Fact(DisplayName = "S4: when a later service fails startup, the processor never polls, and disposing the host leaves nothing running")] + public async Task Failed_Startup_Never_Polls() + { + var queue = new CountingQueue(); + var host = BuildHost(queue, services => services.AddHostedService()); + + await host.Invoking(h => h.StartAsync()).Should().ThrowAsync(); + host.Dispose(); + for (var i = 0; i < 10; i++) await Task.Yield(); + + queue.Receives.Should().Be(0, "workers start only when the host reports ApplicationStarted, which a failed startup never does"); + } + + [Fact(DisplayName = "After a successful startup the processor polls, and stops with the host")] + public async Task Successful_Startup_Polls_And_Stops() + { + var queue = new CountingQueue(); + using var host = BuildHost(queue); + + await host.StartAsync(); + await queue.FirstReceive.Task.WaitAsync(SafetyNet); + await host.StopAsync(); + + queue.Receives.Should().BeGreaterThanOrEqualTo(1); + } +} diff --git a/tests/AzureStorage.QueueService.Tests/QueueProcessorTests.cs b/tests/AzureStorage.QueueService.Tests/QueueProcessorTests.cs new file mode 100644 index 0000000..1ea0f71 --- /dev/null +++ b/tests/AzureStorage.QueueService.Tests/QueueProcessorTests.cs @@ -0,0 +1,666 @@ +using Azure.Storage.Queues.Models; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using Moq; +using Xunit.Abstractions; + +namespace AzureStorage.QueueService.Tests; + +/// +/// Drives the processor against a scripted queue and a fake clock. Nothing here waits on real time: every await is +/// on a signal the processor raises (a receive, a settle, a handler start, a timer registration), with a generous +/// real-time cap only as a safety net against a hung test. +/// +public class QueueProcessorTests(ITestOutputHelper output) +{ + private static readonly TimeSpan SafetyNet = TimeSpan.FromSeconds(10); + + // --------------------------------------------------------------------------------------------------------- + // Test doubles + // --------------------------------------------------------------------------------------------------------- + + private sealed class ScopeMarker; + + private sealed class HandlerState + { + public Func, ScopeMarker, CancellationToken, ValueTask> Behaviour { get; set; } = (_, _, _) => ValueTask.CompletedTask; + public bool ThrowOnConstruct { get; set; } + public List<(string MessageId, ScopeMarker Scope)> Invocations { get; } = new(); + public Signal Started { get; } = new(); + } + + private sealed class RecordingHandler : IQueueMessageHandler + { + private readonly HandlerState _state; + private readonly ScopeMarker _scope; + + public RecordingHandler(HandlerState state, ScopeMarker scope) + { + if (state.ThrowOnConstruct) throw new InvalidOperationException("a dependency is missing"); + _state = state; + _scope = scope; + } + + public async ValueTask HandleAsync(QueueMessage message, CancellationToken cancellationToken) + { + lock (_state.Invocations) _state.Invocations.Add((message.MessageId, _scope)); + _state.Started.Set(); + await _state.Behaviour(message, _scope, cancellationToken); + } + } + + /// + /// A "something happened" signal. WaitUntilAsync checks the condition, and if it is not yet true waits for the + /// next Set() and checks again — grabbing the pending task *before* checking, so a Set() that lands between the + /// check and the wait is never missed. + /// + private sealed class Signal + { + private TaskCompletionSource _tcs = New(); + private static TaskCompletionSource New() => new(TaskCreationOptions.RunContinuationsAsynchronously); + public void Set() => Interlocked.Exchange(ref _tcs, New()).TrySetResult(); + + public async Task WaitUntilAsync(Func condition) + { + while (true) + { + var next = Volatile.Read(ref _tcs).Task; + if (condition()) return; + await next.WaitAsync(SafetyNet); + } + } + } + + /// Each receive hands out the next scripted batch (one message, or empty once the script runs out). + private sealed class FakeQueue : IStorageQueue + { + private readonly Queue[]>> _batches = new(); + public List<(int Take, TimeSpan? Visibility)> Receives { get; } = new(); + public Signal Received { get; } = new(); + public string QueueName => "orders"; + + public FakeQueue Then(QueueMessage message) { lock (_batches) _batches.Enqueue(() => new[] { message }); return this; } + public FakeQueue ThenThrow(Exception exception) { lock (_batches) _batches.Enqueue(() => throw exception); return this; } + + public Task WaitForReceivesAsync(int count) => Received.WaitUntilAsync(() => { lock (Receives) return Receives.Count >= count; }); + + public ValueTask>> ReceiveAsync(int maxMessages = 1, TimeSpan? visibilityTimeout = null, CancellationToken cancellationToken = default) + { + lock (Receives) Receives.Add((maxMessages, visibilityTimeout)); + Func[]>? next; + lock (_batches) next = _batches.Count > 0 ? _batches.Dequeue() : null; + try + { + var batch = next is null ? Array.Empty>() : next(); + return new((IReadOnlyList>)(object)batch); + } + finally + { + Received.Set(); + } + } + + public ValueTask>> PeekAsync(int maxMessages = 1, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public ValueTask SendAsync(T message, SendOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public ValueTask CreateIfNotExistsAsync(IDictionary? metadata = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public ValueTask ClearAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public ValueTask GetPropertiesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + /// Records every settle operation and raises a signal for each, so tests await outcomes instead of sleeping. + private sealed class Operations : IQueueMessageOperations + { + public List<(string Op, string MessageId, string Receipt, TimeSpan? Timeout, string? Reason)> Calls { get; } = new(); + public Signal Called { get; } = new(); + public Exception? DeadLetterFailure { get; set; } + /// When set, visibility updates block on this until it is completed — simulates a renewal request in flight. + public TaskCompletionSource? HoldUpdates { get; set; } + private int _receipts; + + public ValueTask DeleteAsync(string messageId, string popReceipt, CancellationToken cancellationToken) + { + Record("delete", messageId, popReceipt, null, null); + return ValueTask.CompletedTask; + } + + public async ValueTask UpdateVisibilityAsync(string messageId, string popReceipt, TimeSpan visibilityTimeout, CancellationToken cancellationToken) + { + Record("update", messageId, popReceipt, visibilityTimeout, null); + if (HoldUpdates is { } hold) await hold.Task; + return new VisibilityReceipt($"r{Interlocked.Increment(ref _receipts)}", DateTimeOffset.UtcNow + visibilityTimeout); + } + + public ValueTask DeadLetterAsync(string messageId, string popReceipt, long dequeueCount, BinaryData rawBody, string? reason, CancellationToken cancellationToken) + { + Record("deadletter", messageId, popReceipt, null, reason); + if (DeadLetterFailure is not null) throw DeadLetterFailure; + return ValueTask.CompletedTask; + } + + private void Record(string op, string id, string receipt, TimeSpan? timeout, string? reason) + { + lock (Calls) Calls.Add((op, id, receipt, timeout, reason)); + Called.Set(); + } + + public Task WaitForAsync(string op, int count = 1) => + Called.WaitUntilAsync(() => { lock (Calls) return Calls.Count(c => c.Op == op) >= count; }); + + public int Count(string op) { lock (Calls) return Calls.Count(c => c.Op == op); } + } + + /// + /// A fake clock that reports timer registrations, so a test can wait until the processor is actually waiting on a + /// timer before advancing time past it. Advancing before the processor's background thread has registered its + /// timer does nothing, and the test would then pass or fail on thread timing. + /// + private sealed class ObservableTimeProvider : FakeTimeProvider + { + private int _timers; + private readonly Signal _timerCreated = new(); + + public int Timers => Volatile.Read(ref _timers); + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = base.CreateTimer(callback, state, dueTime, period); + Interlocked.Increment(ref _timers); + _timerCreated.Set(); + return timer; + } + + public Task WaitForTimersAsync(int count) => _timerCreated.WaitUntilAsync(() => Timers >= count); + } + + private sealed class Harness : IAsyncDisposable + { + public FakeQueue Queue { get; } = new(); + public Operations Ops { get; } = new(); + public HandlerState State { get; } = new(); + public ObservableTimeProvider Time { get; } = new(); + public QueueProcessor Processor { get; } + + public Harness(ITestOutputHelper output, Action? configure = null, IStorageQueue? queue = null) + { + var services = new ServiceCollection(); + services.AddLogging(logging => logging.AddProvider(new XUnitLoggerProvider(output))); + services.AddSingleton(State); + services.AddScoped(); + services.AddScoped(); + services.Configure("opts", o => + { + o.EmptyQueueBackoffMin = TimeSpan.FromSeconds(1); + o.EmptyQueueBackoffMax = TimeSpan.FromSeconds(8); + configure?.Invoke(o); + }); + var provider = services.BuildServiceProvider(); + + var factory = new Mock(); + factory.Setup(x => x.GetQueue("orders")).Returns(queue ?? Queue); + + Processor = new QueueProcessor( + "orders", "opts", factory.Object, + provider.GetRequiredService(), + provider.GetRequiredService>(), + provider.GetRequiredService(), + Time); + } + + public QueueMessage Message(string id, long dequeueCount = 1, DeserializationException? error = null) => + new(new QueueMessageInfo(id, "r0", dequeueCount, null, null, null), BinaryData.FromString("{}"), error is null ? new TestObject { Property1 = id } : null, error, Ops); + + public async Task StartAsync() + { + await Processor.StartAsync(CancellationToken.None); + await Processor.StartedAsync(CancellationToken.None); + } + + public Task StopAsync() => Processor.StopAsync(CancellationToken.None).WaitAsync(SafetyNet); + + public Task WaitForHandlerStartAsync(int count = 1) => + State.Started.WaitUntilAsync(() => { lock (State.Invocations) return State.Invocations.Count >= count; }); + + /// + /// Waits until the processor has registered its th timer (counted from the start + /// of the test), then moves the clock forward. Absolute numbers, because "the next timer" is ambiguous when the + /// processor may already have registered it. + /// + public async Task AdvancePastTimerAsync(int timerNumber, TimeSpan by) + { + await Time.WaitForTimersAsync(timerNumber); + Time.Advance(by); + } + + public async ValueTask DisposeAsync() + { + try { await StopAsync(); } catch (Exception) { /* a test that cancelled shutdown on purpose */ } + } + } + + // --------------------------------------------------------------------------------------------------------- + // Delivery + // --------------------------------------------------------------------------------------------------------- + + [Fact(DisplayName = "Each worker receives one message, handles it in its own scope, and completes it")] + public async Task Each_Worker_Handles_One_Message_In_Its_Own_Scope() + { + await using var h = new Harness(output, o => o.MaxConcurrentMessages = 2); + h.Queue.Then(h.Message("1")).Then(h.Message("2")); + + await h.StartAsync(); + await h.Ops.WaitForAsync("delete", count: 2); + + h.State.Invocations.Select(i => i.MessageId).Should().BeEquivalentTo("1", "2"); + h.State.Invocations.Select(i => i.Scope).Distinct().Should().HaveCount(2, "every message gets a fresh scope"); + h.Queue.Receives.Should().OnlyContain(r => r.Take == 1 && r.Visibility == TimeSpan.FromMinutes(1)); + } + + [Fact(DisplayName = "A throwing handler abandons the message with the retry delay for its dequeue count")] + public async Task Throwing_Handler_Abandons_With_Retry_Delay() + { + await using var h = new Harness(output); + h.State.Behaviour = (_, _, _) => throw new InvalidOperationException("boom"); + h.Queue.Then(h.Message("1", dequeueCount: 3)); + + await h.StartAsync(); + await h.Ops.WaitForAsync("update"); + + h.Ops.Calls.Should().ContainSingle(c => c.Op == "update").Which.Timeout.Should().Be(TimeSpan.FromSeconds(20), "5 s doubled twice for the third attempt"); + h.Ops.Count("delete").Should().Be(0); + } + + [Fact(DisplayName = "A message over the dequeue limit is dead-lettered without calling the handler")] + public async Task Over_Dequeue_Limit_Is_Dead_Lettered() + { + await using var h = new Harness(output, o => o.MaxDequeueCount = 5); + h.Queue.Then(h.Message("1", dequeueCount: 6)); + + await h.StartAsync(); + await h.Ops.WaitForAsync("deadletter"); + + h.Ops.Calls.Should().ContainSingle(c => c.Op == "deadletter").Which.Reason.Should().Contain("6"); + h.State.Invocations.Should().BeEmpty(); + } + + [Fact(DisplayName = "A message that failed to decode or deserialize is dead-lettered without calling the handler")] + public async Task Deserialization_Failure_Is_Dead_Lettered() + { + await using var h = new Harness(output); + h.Queue.Then(h.Message("1", error: new DeserializationException("bad json"))); + + await h.StartAsync(); + await h.Ops.WaitForAsync("deadletter"); + + h.Ops.Calls.Should().ContainSingle(c => c.Op == "deadletter").Which.Reason.Should().Contain("bad json"); + h.State.Invocations.Should().BeEmpty(); + } + + [Fact(DisplayName = "When dead-lettering fails the message is abandoned instead of lost")] + public async Task Dead_Letter_Failure_Falls_Back_To_Abandon() + { + await using var h = new Harness(output); + h.Ops.DeadLetterFailure = new UnauthorizedAccessException("no permission to create the poison queue"); + h.Queue.Then(h.Message("1", dequeueCount: 6)); + + await h.StartAsync(); + await h.Ops.WaitForAsync("update"); + + h.Ops.Calls.Select(c => c.Op).Should().ContainInOrder("deadletter", "update"); + } + + [Fact(DisplayName = "A handler that settles the message itself is left alone")] + public async Task Handler_That_Settles_Itself_Is_Left_Alone() + { + await using var h = new Harness(output); + h.State.Behaviour = async (message, _, _) => await message.DeadLetterAsync("handler decided"); + h.Queue.Then(h.Message("1")); + + await h.StartAsync(); + await h.Ops.WaitForAsync("deadletter"); + await h.Queue.WaitForReceivesAsync(2); // the worker has moved on, so delivery of message 1 is finished + + h.Ops.Count("deadletter").Should().Be(1); + h.Ops.Count("delete").Should().Be(0, "the processor must not complete a message the handler dead-lettered"); + } + + [Fact(DisplayName = "A handler that completes the message and then throws keeps the completion; the message is not abandoned")] + public async Task Handler_That_Settles_Then_Throws_Keeps_The_Settlement() + { + await using var h = new Harness(output); + h.State.Behaviour = async (message, _, _) => + { + await message.CompleteAsync(); + throw new InvalidOperationException("failed while cleaning up"); + }; + h.Queue.Then(h.Message("1")); + + await h.StartAsync(); + await h.Ops.WaitForAsync("delete"); + await h.Queue.WaitForReceivesAsync(2); // the worker has moved on, so delivery of message 1 is finished + + h.Ops.Count("delete").Should().Be(1); + h.Ops.Count("update").Should().Be(0, "a completed message must not be abandoned"); + } + + // --------------------------------------------------------------------------------------------------------- + // Lease renewal + // --------------------------------------------------------------------------------------------------------- + + [Fact(DisplayName = "The lease is renewed at half the visibility timeout while the handler runs, and completion uses the renewed receipt")] + public async Task Lease_Is_Renewed_While_Handler_Runs() + { + await using var h = new Harness(output, o => o.VisibilityTimeout = TimeSpan.FromMinutes(1)); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + h.State.Behaviour = async (_, _, _) => await release.Task; + h.Queue.Then(h.Message("1")); + + await h.StartAsync(); + await h.WaitForHandlerStartAsync(); + + await h.AdvancePastTimerAsync(1, TimeSpan.FromSeconds(30)); // the renewal timer + await h.Ops.WaitForAsync("update"); + var renewal = h.Ops.Calls.Should().ContainSingle(c => c.Op == "update").Subject; + renewal.Timeout.Should().Be(TimeSpan.FromMinutes(1)); + renewal.Receipt.Should().Be("r0"); + + release.SetResult(); + await h.Ops.WaitForAsync("delete"); + h.Ops.Calls.Should().ContainSingle(c => c.Op == "delete").Which.Receipt.Should().Be("r1", "the delete must use the receipt the renewal returned"); + } + + [Fact(DisplayName = "R2: lease renewal is armed before the message is deserialized, so a slow serializer cannot let the lease lapse")] + public async Task Renewal_Is_Armed_Before_Deserialization() + { + using var block = new ManualResetEventSlim(false); + var updated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var deleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var first = true; + var client = new Mock(); + client.Setup(x => x.Name).Returns("orders"); + client.Setup(x => x.ReceiveMessagesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + var batch = first ? new[] { QueuesModelFactory.QueueMessage("1", "r0", "{}", 1) } : Array.Empty(); + first = false; + return Azure.Response.FromValue(batch, Mock.Of()); + }); + client.Setup(x => x.UpdateMessageAsync("1", "r0", (string?)null, TimeSpan.FromMinutes(1), It.IsAny())) + .Callback(() => updated.TrySetResult()) + .ReturnsAsync(Azure.Response.FromValue(QueuesModelFactory.UpdateReceipt("r1", DateTimeOffset.UtcNow.AddMinutes(1)), Mock.Of())); + client.Setup(x => x.DeleteMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(() => deleted.TrySetResult()) + .ReturnsAsync(Mock.Of()); + + var loggerFactory = new ServiceCollection().AddLogging().BuildServiceProvider().GetRequiredService(); + var queue = new StorageQueue(client.Object, new BlockingSerializer(block), loggerFactory, new Telemetry.QueueServiceTelemetrySettings(), null, false, null); + await using var h = new Harness(output, o => o.VisibilityTimeout = TimeSpan.FromMinutes(1), queue); + + await h.StartAsync(); + await h.Time.WaitForTimersAsync(1); // the renewal timer exists... + block.IsSet.Should().BeFalse("...while deserialization has not been allowed to finish"); + + h.Time.Advance(TimeSpan.FromSeconds(30)); // a renewal fires during deserialization + await updated.Task.WaitAsync(SafetyNet); + + block.Set(); + await deleted.Task.WaitAsync(SafetyNet); + client.Verify(x => x.DeleteMessageAsync("1", "r1", It.IsAny()), Times.Once, "completion uses the receipt from the renewal that ran during deserialization"); + } + + private sealed class BlockingSerializer(ManualResetEventSlim block) : IQueueMessageSerializer + { + public BinaryData Serialize(T value) => BinaryData.FromString("{}"); + + public T? Deserialize(BinaryData data) + { + block.Wait(SafetyNet); + return (T)(object)new TestObject { Property1 = "slow" }; + } + } + + [Fact(DisplayName = "F3: finishing the handler while a renewal is in flight waits for it instead of cancelling it")] + public async Task Completion_Waits_For_In_Flight_Renewal() + { + await using var h = new Harness(output, o => o.VisibilityTimeout = TimeSpan.FromMinutes(1)); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + h.State.Behaviour = async (_, _, _) => await release.Task; + h.Ops.HoldUpdates = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + h.Queue.Then(h.Message("1")); + + await h.StartAsync(); + await h.WaitForHandlerStartAsync(); + + await h.AdvancePastTimerAsync(1, TimeSpan.FromSeconds(30)); + await h.Ops.WaitForAsync("update"); // the renewal request is now "on the wire" and blocked + + release.SetResult(); // the handler finishes while the renewal is still pending + await Task.Yield(); + h.Ops.Count("delete").Should().Be(0, "completion must wait for the renewal's receipt"); + + h.Ops.HoldUpdates.SetResult(); // the service answers with the new receipt + await h.Ops.WaitForAsync("delete"); + h.Ops.Calls.Should().ContainSingle(c => c.Op == "delete").Which.Receipt.Should().Be("r1"); + } + + // --------------------------------------------------------------------------------------------------------- + // Polling + // --------------------------------------------------------------------------------------------------------- + + [Fact(DisplayName = "An empty queue is polled with exponential backoff")] + public async Task Empty_Queue_Backs_Off_Exponentially() + { + await using var h = new Harness(output); + + await h.StartAsync(); + await h.Queue.WaitForReceivesAsync(1); + + await h.AdvancePastTimerAsync(1, TimeSpan.FromSeconds(1)); // first backoff: 1 s + await h.Queue.WaitForReceivesAsync(2); + + await h.AdvancePastTimerAsync(2, TimeSpan.FromSeconds(1)); // second backoff: 2 s, so 1 s is not enough + h.Queue.Receives.Should().HaveCount(2); + h.Time.Advance(TimeSpan.FromSeconds(1)); + await h.Queue.WaitForReceivesAsync(3); + } + + [Fact(DisplayName = "A failed receive is logged, backed off, and polling continues")] + public async Task Receive_Failure_Backs_Off_And_Continues() + { + await using var h = new Harness(output); + h.Queue.ThenThrow(new Azure.RequestFailedException(503, "storage is busy")).Then(h.Message("1")); + + await h.StartAsync(); + await h.Queue.WaitForReceivesAsync(1); + + await h.AdvancePastTimerAsync(1, TimeSpan.FromSeconds(1)); // the error backoff + await h.Ops.WaitForAsync("delete"); + + h.State.Invocations.Should().ContainSingle(); + } + + [Fact(DisplayName = "F4: a handler that blocks synchronously does not hold up the other workers")] + public async Task Blocking_Handler_Does_Not_Hold_Up_Other_Workers() + { + await using var h = new Harness(output, o => { o.MaxConcurrentMessages = 2; o.RenewVisibility = false; }); + using var block = new ManualResetEventSlim(false); + h.State.Behaviour = (message, _, _) => + { + if (message.MessageId == "1") block.Wait(SafetyNet); // synchronous, thread-blocking work + return ValueTask.CompletedTask; + }; + h.Queue.Then(h.Message("1")).Then(h.Message("2")); + + await h.StartAsync(); + await h.Ops.WaitForAsync("delete"); // message 2 completes while message 1's handler is still blocked + + h.Ops.Calls.Should().ContainSingle(c => c.Op == "delete").Which.MessageId.Should().Be("2"); + block.Set(); + await h.Ops.WaitForAsync("delete", count: 2); + } + + // --------------------------------------------------------------------------------------------------------- + // Failures outside the handler + // --------------------------------------------------------------------------------------------------------- + + [Fact(DisplayName = "F5: a handler whose constructor throws is logged and its message abandoned for retry")] + public async Task Handler_Construction_Failure_Is_Abandoned() + { + await using var h = new Harness(output); + h.State.ThrowOnConstruct = true; + h.Queue.Then(h.Message("1")); + + await h.StartAsync(); + await h.Ops.WaitForAsync("update"); + await h.Queue.WaitForReceivesAsync(2); // the worker carries on + + h.Ops.Calls.Should().ContainSingle(c => c.Op == "update").Which.Timeout.Should().Be(TimeSpan.FromSeconds(5)); + h.Ops.Count("delete").Should().Be(0); + h.State.Invocations.Should().BeEmpty(); + } + + [Fact(DisplayName = "F5: a throwing RetryDelay callback falls back to five minutes instead of losing the message")] + public async Task Throwing_RetryDelay_Falls_Back() + { + await using var h = new Harness(output, o => o.RetryDelay = _ => throw new InvalidOperationException("bad callback")); + h.State.Behaviour = (_, _, _) => throw new InvalidOperationException("boom"); + h.Queue.Then(h.Message("1")); + + await h.StartAsync(); + await h.Ops.WaitForAsync("update"); + + h.Ops.Calls.Should().ContainSingle(c => c.Op == "update").Which.Timeout.Should().Be(TimeSpan.FromMinutes(5)); + } + + // --------------------------------------------------------------------------------------------------------- + // Shutdown + // --------------------------------------------------------------------------------------------------------- + + [Fact(DisplayName = "Shutdown waits for a running handler and completes its message")] + public async Task Shutdown_Waits_For_Running_Handler() + { + await using var h = new Harness(output, o => o.RenewVisibility = false); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + h.State.Behaviour = async (_, _, _) => await release.Task; + h.Queue.Then(h.Message("1")); + + await h.StartAsync(); + await h.WaitForHandlerStartAsync(); + + var stopping = h.Processor.StopAsync(CancellationToken.None); + stopping.IsCompleted.Should().BeFalse("the handler is still running"); + + release.SetResult(); + await stopping.WaitAsync(SafetyNet); + + h.Ops.Calls.Should().ContainSingle(c => c.Op == "delete"); + } + + [Fact(DisplayName = "Shutdown cancels a handler that outlives the drain timeout, and its message is returned to the queue")] + public async Task Shutdown_Cancels_Handler_After_Drain_Timeout() + { + await using var h = new Harness(output, o => { o.RenewVisibility = false; o.ShutdownDrainTimeout = TimeSpan.FromSeconds(30); }); + h.State.Behaviour = async (_, _, ct) => await Task.Delay(Timeout.InfiniteTimeSpan, ct); + h.Queue.Then(h.Message("1")); + + await h.StartAsync(); + await h.WaitForHandlerStartAsync(); + + var stopping = h.Processor.StopAsync(CancellationToken.None); + await h.AdvancePastTimerAsync(1, TimeSpan.FromSeconds(30)); // the drain timer is the only one: no renewal, and the worker is inside the handler + await stopping.WaitAsync(SafetyNet); + + h.Ops.Calls.Should().ContainSingle(c => c.Op == "update").Which.Timeout.Should().Be(TimeSpan.Zero, "another instance should be able to pick it up right away"); + h.Ops.Count("delete").Should().Be(0); + } + + [Fact(DisplayName = "F1: when the host's shutdown deadline cancels StopAsync, it returns without throwing and still cancels handlers and stops renewal")] + public async Task Host_Deadline_Still_Cancels_Handlers_And_Renewal() + { + await using var h = new Harness(output, o => o.VisibilityTimeout = TimeSpan.FromMinutes(1)); + h.State.Behaviour = async (_, _, ct) => await Task.Delay(Timeout.InfiniteTimeSpan, ct); + h.Queue.Then(h.Message("1")); + + await h.StartAsync(); + await h.WaitForHandlerStartAsync(); + await h.Time.WaitForTimersAsync(1); // the renewal timer is armed + + using var hostDeadline = new CancellationTokenSource(); + hostDeadline.Cancel(); + await h.Processor.Invoking(p => p.StopAsync(hostDeadline.Token)).Should().NotThrowAsync("the host reports a throwing StopAsync as a failed shutdown"); + + await h.Ops.WaitForAsync("update"); // the handler was cancelled and its message abandoned + h.Ops.Calls.Should().ContainSingle(c => c.Op == "update").Which.Timeout.Should().Be(TimeSpan.Zero); + + h.Time.Advance(TimeSpan.FromMinutes(5)); + h.Ops.Calls.Should().NotContain(c => c.Op == "update" && c.Timeout == TimeSpan.FromMinutes(1), "renewal must stop once StopAsync has exited"); + } + + [Fact(DisplayName = "R3: at the drain deadline, renewal stops even for a handler that ignores cancellation, and shutdown stops waiting after a second drain timeout")] + public async Task Drain_Deadline_Stops_Renewal_For_Uncooperative_Handler() + { + await using var h = new Harness(output, o => { o.VisibilityTimeout = TimeSpan.FromMinutes(10); o.ShutdownDrainTimeout = TimeSpan.FromSeconds(30); }); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + h.State.Behaviour = async (_, _, _) => await release.Task; // ignores its cancellation token + h.Queue.Then(h.Message("1")); + + await h.StartAsync(); + await h.WaitForHandlerStartAsync(); + await h.Time.WaitForTimersAsync(1); // the renewal timer (5 minutes) + + var stopping = h.Processor.StopAsync(CancellationToken.None); + await h.AdvancePastTimerAsync(2, TimeSpan.FromSeconds(30)); // the drain deadline passes; the handler is cancelled but ignores it + await h.Time.WaitForTimersAsync(3); // the second, bounded wait is armed + + h.Time.Advance(TimeSpan.FromMinutes(10)); // long past when a renewal would have fired, and past the second wait + await stopping.WaitAsync(SafetyNet); + + h.Ops.Count("update").Should().Be(0, "renewal must stop at the drain deadline even though the handler is still running"); + release.SetResult(); + } + + [Theory(DisplayName = "R4: durations the timer or the service would refuse fail at startup, not at the first wait")] + [InlineData("EmptyQueueBackoffMax")] + [InlineData("ErrorBackoffMax")] + [InlineData("ShutdownDrainTimeout")] + [InlineData("VisibilityTimeout")] + public async Task Oversized_Durations_Fail_At_Startup(string option) + { + await using var h = new Harness(output, o => + { + switch (option) + { + case "EmptyQueueBackoffMax": o.EmptyQueueBackoffMax = TimeSpan.FromDays(60); break; + case "ErrorBackoffMax": o.ErrorBackoffMax = TimeSpan.FromDays(60); break; + case "ShutdownDrainTimeout": o.ShutdownDrainTimeout = TimeSpan.FromDays(60); break; + case "VisibilityTimeout": o.VisibilityTimeout = TimeSpan.FromDays(8); break; + } + }); + + await h.Processor.Invoking(p => p.StartAsync(CancellationToken.None)).Should().ThrowAsync().WithMessage($"*{option}*"); + } + + [Theory(DisplayName = "S3: a processor VisibilityTimeout under one second or with a fraction of a second is rejected at startup; whole seconds are accepted")] + [InlineData(0.5, false)] + [InlineData(1.5, false)] + [InlineData(1, true)] + public async Task Sub_Second_Visibility_Fails_At_Startup(double seconds, bool valid) + { + await using var h = new Harness(output, o => o.VisibilityTimeout = TimeSpan.FromSeconds(seconds)); + + var act = h.Processor.Invoking(p => p.StartAsync(CancellationToken.None)); + if (valid) await act.Should().NotThrowAsync(); + else await act.Should().ThrowAsync().WithMessage("*VisibilityTimeout*"); + } + + [Fact(DisplayName = "Invalid options fail at startup, not at the first message")] + public async Task Invalid_Options_Fail_At_Startup() + { + await using var h = new Harness(output, o => o.MaxConcurrentMessages = 0); + + await h.Processor.Invoking(p => p.StartAsync(CancellationToken.None)).Should().ThrowAsync().WithMessage("*MaxConcurrentMessages*"); + } +} diff --git a/tests/AzureStorage.QueueService.Tests/SdkQueueClientFactoryTests.cs b/tests/AzureStorage.QueueService.Tests/SdkQueueClientFactoryTests.cs new file mode 100644 index 0000000..a85b35d --- /dev/null +++ b/tests/AzureStorage.QueueService.Tests/SdkQueueClientFactoryTests.cs @@ -0,0 +1,64 @@ +using Azure.Identity; +using FluentAssertions; + +namespace AzureStorage.QueueService.Tests; + +public class SdkQueueClientFactoryTests +{ + private const string Description = "Queue client 'test'"; + + [Fact] + public void CreateClient_WithEndpointAndCredential_ReturnsQueueClient() + { + var settings = new QueueClientSettings + { + TokenCredential = new DefaultAzureCredential(), + EndpointUri = new Uri("https://fake.com/queue1"), + }; + + new SdkQueueClientFactory().CreateClient(Description, settings).Name.Should().Be("queue1"); + } + + [Fact] + public void CreateClient_WithConnectionString_ReturnsQueueClient() + { + var settings = new QueueClientSettings + { + ConnectionString = "UseDevelopmentStorage=true", + QueueName = "queue1", + }; + + new SdkQueueClientFactory().CreateClient(Description, settings).Name.Should().Be("queue1"); + } + + [Fact] + public void CreateClient_WithoutUri_ThrowsNamingTheClient() + { + var settings = new QueueClientSettings + { + TokenCredential = new DefaultAzureCredential(), + QueueName = "queue1", + }; + + new SdkQueueClientFactory().Invoking(x => x.CreateClient(Description, settings)) + .Should().Throw().WithMessage($"{Description}*"); + } + + [Fact] + public void CreateClient_WithConnectionStringButNoQueueName_Throws() + { + var settings = new QueueClientSettings { ConnectionString = "UseDevelopmentStorage=true" }; + + new SdkQueueClientFactory().Invoking(x => x.CreateClient(Description, settings)) + .Should().Throw().WithMessage("*QueueName*"); + } + + [Fact] + public void CreateClient_WithNothingConfigured_Throws() + { + var settings = new QueueClientSettings { QueueName = "queue1" }; + + new SdkQueueClientFactory().Invoking(x => x.CreateClient(Description, settings)) + .Should().Throw(); + } +} diff --git a/tests/AzureStorage.QueueService.Tests/StorageQueueTests.cs b/tests/AzureStorage.QueueService.Tests/StorageQueueTests.cs new file mode 100644 index 0000000..a4902f5 --- /dev/null +++ b/tests/AzureStorage.QueueService.Tests/StorageQueueTests.cs @@ -0,0 +1,296 @@ +using System.Diagnostics; +using Azure; +using Azure.Storage.Queues; +using Azure.Storage.Queues.Models; +using AzureStorage.QueueService.Telemetry; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace AzureStorage.QueueService.Tests; + +public class StorageQueueTests +{ + private static readonly ILoggerFactory LoggerFactory = new ServiceCollection().AddLogging().BuildServiceProvider().GetRequiredService(); + private const string ValidJson = "{\"Property1\":\"test\",\"Property2\":true,\"Property3\":1}"; + + private static Mock ClientReturning(params (string Id, string Body)[] messages) + { + var sdkMessages = messages.Select(m => QueuesModelFactory.QueueMessage(m.Id, $"receipt-{m.Id}", m.Body, 2)).ToArray(); + var mock = new Mock(); + mock.Setup(x => x.Name).Returns("orders"); + mock.Setup(x => x.ReceiveMessagesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(sdkMessages, Mock.Of())); + return mock; + } + + private static StorageQueue Create(Mock client, Func? poisonQueue = null, TimeSpan? visibilityTimeout = null, bool createIfNotExists = false, QueueServiceTelemetrySettings? telemetry = null) => + new(client.Object, new JsonQueueMessageSerializer(), LoggerFactory, telemetry ?? new QueueServiceTelemetrySettings(), visibilityTimeout, createIfNotExists, poisonQueue); + + private static void SetupUpdate(Mock client, string expectedReceipt, TimeSpan expectedTimeout, string newReceipt) => + client.Setup(x => x.UpdateMessageAsync(It.IsAny(), expectedReceipt, (string?)null, expectedTimeout, It.IsAny())) + .ReturnsAsync(Response.FromValue(QueuesModelFactory.UpdateReceipt(newReceipt, DateTimeOffset.UtcNow + expectedTimeout), Mock.Of())); + + [Fact(DisplayName = "Receive maps the message identity and deserializes the body")] + public async Task Receive_Maps_Identity_And_Body() + { + var message = (await Create(ClientReturning(("1", ValidJson))).ReceiveAsync()).Should().ContainSingle().Subject; + + message.MessageId.Should().Be("1"); + message.PopReceipt.Should().Be("receipt-1"); + message.DequeueCount.Should().Be(2); + message.Body!.Property1.Should().Be("test"); + message.Error.Should().BeNull(); + message.Settlement.Should().Be(MessageSettlement.None); + } + + [Fact(DisplayName = "A body that cannot be deserialized is returned with Error set instead of throwing")] + public async Task Receive_Bad_Body_Sets_Error() + { + var message = (await Create(ClientReturning(("1", "not json"))).ReceiveAsync()).Should().ContainSingle().Subject; + + message.Body.Should().BeNull(); + message.Error.Should().BeOfType(); + message.RawBody.ToString().Should().Be("not json"); + } + + [Fact(DisplayName = "F2: a message the SDK could not Base64-decode comes back with Error set and can be dead-lettered")] + public async Task Receive_Undecodable_Message_Is_Returned_With_Error() + { + // The SDK raises MessageDecodingFailed inside the receive call and drops the message from the batch; + // simulate exactly that from the mock, on the same async flow. + var client = new Mock(); + client.Setup(x => x.Name).Returns("orders"); + var undecodable = QueuesModelFactory.QueueMessage("bad", "receipt-bad", "this is not base64!!", 4); + var healthy = QueuesModelFactory.QueueMessage("1", "receipt-1", ValidJson, 1); + client.Setup(x => x.ReceiveMessagesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(() => + { + DecodingFailures.TryCollect(new QueueMessageDecodingFailedEventArgs(client.Object, undecodable, null, false, CancellationToken.None)).Should().BeTrue(); + return Task.FromResult(Response.FromValue(new[] { healthy }, Mock.Of())); + }); + var poison = new Mock(); + + var messages = await Create(client, () => poison.Object).ReceiveAsync(maxMessages: 10); + + messages.Should().HaveCount(2); + messages.Single(m => m.Error is null).Body!.Property1.Should().Be("test"); + var bad = messages.Single(m => m.Error is not null); + bad.MessageId.Should().Be("bad"); + bad.DequeueCount.Should().Be(4); + bad.RawBody.ToString().Should().Be("this is not base64!!"); + + await bad.DeadLetterAsync("undecodable"); + poison.Verify(x => x.SendMessageAsync(It.Is(b => b.ToString() == "this is not base64!!"), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + client.Verify(x => x.DeleteMessageAsync("bad", "receipt-bad", It.IsAny()), Times.Once); + } + + [Fact(DisplayName = "Decoding failures are isolated per receive, even when two queues receive at the same moment")] + public async Task Decoding_Failures_Are_Isolated_Per_Receive() + { + // Both receives are in flight together: each waits for the other to start before raising its decoding event. + var bothStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var started = 0; + + StorageQueue Build(string badId) + { + var client = new Mock(); + client.Setup(x => x.Name).Returns($"queue-{badId}"); + client.Setup(x => x.ReceiveMessagesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async () => + { + if (Interlocked.Increment(ref started) == 2) bothStarted.TrySetResult(); + await bothStarted.Task; + DecodingFailures.TryCollect(new QueueMessageDecodingFailedEventArgs(client.Object, QueuesModelFactory.QueueMessage(badId, "r", "??", 1), null, false, CancellationToken.None)).Should().BeTrue(); + return Response.FromValue(Array.Empty(), Mock.Of()); + }); + return Create(client); + } + + var one = Build("bad-1").ReceiveAsync().AsTask(); + var two = Build("bad-2").ReceiveAsync().AsTask(); + await Task.WhenAll(one, two).WaitAsync(TimeSpan.FromSeconds(10)); + + (await one).Should().ContainSingle().Which.MessageId.Should().Be("bad-1"); + (await two).Should().ContainSingle().Which.MessageId.Should().Be("bad-2"); + } + + [Theory(DisplayName = "Receive rejects a message count the service would refuse")] + [InlineData(0)] + [InlineData(33)] + public async Task Receive_Rejects_Out_Of_Range_Count(int maxMessages) + { + Func act = async () => await Create(ClientReturning()).ReceiveAsync(maxMessages); + await act.Should().ThrowAsync(); + } + + [Fact(DisplayName = "The client's default visibility timeout is used when the caller passes none")] + public async Task Receive_Uses_Default_Visibility_Timeout() + { + var client = ClientReturning(); + await Create(client, visibilityTimeout: TimeSpan.FromMinutes(5)).ReceiveAsync(); + client.Verify(x => x.ReceiveMessagesAsync(1, TimeSpan.FromMinutes(5), It.IsAny()), Times.Once); + } + + [Fact(DisplayName = "F8: the receive span actually carries its tags")] + public async Task Receive_Span_Carries_Queue_Name_Tag() + { + var started = new List(); + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == QueueServiceDiagnostics.ServiceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = started.Add, + }; + ActivitySource.AddActivityListener(listener); + + await Create(ClientReturning(("1", ValidJson)), telemetry: new QueueServiceTelemetrySettings { CreateNewActivityOnMessageRetrieval = true }) + .ReceiveAsync(maxMessages: 3); + + var read = started.Should().ContainSingle(a => a.OperationName == QueueServiceDiagnostics.Names.ReadingMessages).Subject; + read.GetTagItem(QueueServiceDiagnostics.Names.QueueName).Should().Be("orders"); + read.GetTagItem(QueueServiceDiagnostics.Names.QueueMessageCount).Should().Be(3); + } + + [Fact(DisplayName = "Complete deletes with the current receipt, settles, and refuses a second operation")] + public async Task Complete_Deletes_And_Settles() + { + var client = ClientReturning(("1", ValidJson)); + var message = (await Create(client).ReceiveAsync())[0]; + + await message.CompleteAsync(); + + client.Verify(x => x.DeleteMessageAsync("1", "receipt-1", It.IsAny()), Times.Once); + message.Settlement.Should().Be(MessageSettlement.Completed); + await message.Invoking(m => m.CompleteAsync().AsTask()).Should().ThrowAsync(); + } + + [Fact(DisplayName = "Renewing the lock updates the receipt and a later complete uses the new one")] + public async Task Renew_Then_Complete_Uses_New_Receipt() + { + var client = ClientReturning(("1", ValidJson)); + SetupUpdate(client, "receipt-1", TimeSpan.FromMinutes(2), "receipt-2"); + var message = (await Create(client).ReceiveAsync())[0]; + + await message.RenewLockAsync(TimeSpan.FromMinutes(2)); + await message.CompleteAsync(); + + message.PopReceipt.Should().Be("receipt-2"); + client.Verify(x => x.DeleteMessageAsync("1", "receipt-2", It.IsAny()), Times.Once); + } + + [Fact(DisplayName = "Abandon makes the message visible immediately and settles it")] + public async Task Abandon_Makes_Visible_Now() + { + var client = ClientReturning(("1", ValidJson)); + SetupUpdate(client, "receipt-1", TimeSpan.Zero, "receipt-2"); + var message = (await Create(client).ReceiveAsync())[0]; + + await message.AbandonAsync(); + + message.Settlement.Should().Be(MessageSettlement.Abandoned); + client.Verify(x => x.UpdateMessageAsync("1", "receipt-1", (string?)null, TimeSpan.Zero, It.IsAny()), Times.Once); + } + + [Fact(DisplayName = "Dead-letter copies the raw body to the poison queue unchanged, then deletes the original")] + public async Task DeadLetter_Copies_Raw_Body_Then_Deletes() + { + var client = ClientReturning(("1", ValidJson)); + var poison = new Mock(); + var message = (await Create(client, () => poison.Object).ReceiveAsync())[0]; + + await message.DeadLetterAsync("gave up"); + + poison.Verify(x => x.CreateIfNotExistsAsync(It.IsAny>(), It.IsAny()), Times.Once); + poison.Verify(x => x.SendMessageAsync(It.Is(b => b.ToString() == ValidJson), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + client.Verify(x => x.DeleteMessageAsync("1", "receipt-1", It.IsAny()), Times.Once); + message.Settlement.Should().Be(MessageSettlement.DeadLettered); + } + + [Fact(DisplayName = "Dead-letter without a poison queue configured throws instead of losing the message")] + public async Task DeadLetter_Without_Poison_Queue_Throws() + { + var client = ClientReturning(("1", ValidJson)); + var message = (await Create(client).ReceiveAsync())[0]; + + await message.Invoking(m => m.DeadLetterAsync().AsTask()).Should().ThrowAsync(); + client.Verify(x => x.DeleteMessageAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + message.IsSettled.Should().BeFalse(); + } + + [Fact(DisplayName = "Send serializes with the queue's serializer and passes the options through")] + public async Task Send_Serializes_And_Passes_Options() + { + var client = new Mock(); + var receipt = QueuesModelFactory.SendReceipt("1", DateTimeOffset.Now, DateTimeOffset.Now, "r", DateTimeOffset.Now); + client.Setup(x => x.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(receipt, Mock.Of())); + + var result = await Create(client).SendAsync(new TestObject { Property1 = "sent" }, new SendOptions(TimeSpan.FromSeconds(10), TimeSpan.FromHours(1))); + + result.Should().BeSameAs(receipt); + client.Verify(x => x.SendMessageAsync(It.Is(b => b.ToString().Contains("\"Property1\":\"sent\"")), TimeSpan.FromSeconds(10), TimeSpan.FromHours(1), It.IsAny()), Times.Once); + } + + [Theory(DisplayName = "Send translates the .NET infinite TTL and preserves other TTL values")] + [InlineData(null, null)] + [InlineData(-1, -1000)] + [InlineData(-1000, -1000)] + [InlineData(3600000, 3600000)] + public async Task Send_Normalizes_Infinite_TimeToLive(int? milliseconds, int? expectedMilliseconds) + { + var client = new Mock(); + var receipt = QueuesModelFactory.SendReceipt("1", DateTimeOffset.Now, DateTimeOffset.Now, "r", DateTimeOffset.Now); + client.Setup(x => x.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(receipt, Mock.Of())); + TimeSpan? timeToLive = milliseconds is { } value ? TimeSpan.FromMilliseconds(value) : null; + TimeSpan? expected = expectedMilliseconds is { } expectedValue ? TimeSpan.FromMilliseconds(expectedValue) : null; + + await Create(client).SendAsync(new TestObject(), new SendOptions(TimeToLive: timeToLive)); + + client.Verify(x => x.SendMessageAsync(It.IsAny(), null, expected, It.IsAny()), Times.Once); + } + + [Fact(DisplayName = "Peek returns messages without a receipt or operations")] + public async Task Peek_Returns_Peeked_Messages() + { + var client = new Mock(); + client.Setup(x => x.PeekMessagesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(new[] { QueuesModelFactory.PeekedMessage("1", ValidJson, 3) }, Mock.Of())); + + var peeked = (await Create(client).PeekAsync()).Should().ContainSingle().Subject; + + peeked.MessageId.Should().Be("1"); + peeked.DequeueCount.Should().Be(3); + peeked.Body!.Property1.Should().Be("test"); + } + + [Fact(DisplayName = "F7: reading properties creates the queue first when CreateIfNotExists is on")] + public async Task GetProperties_Creates_Queue_First() + { + var client = new Mock(); + var order = new List(); + client.Setup(x => x.CreateIfNotExistsAsync(It.IsAny>(), It.IsAny())) + .Callback(() => order.Add("create")).ReturnsAsync(Mock.Of()); + client.Setup(x => x.GetPropertiesAsync(It.IsAny())) + .Callback(() => order.Add("properties")).ReturnsAsync(Response.FromValue(QueuesModelFactory.QueueProperties(new Dictionary(), 0), Mock.Of())); + + await Create(client, createIfNotExists: true).GetPropertiesAsync(); + + order.Should().Equal("create", "properties"); + } + + [Fact(DisplayName = "A test can build a QueueMessage with stubbed operations")] + public async Task QueueMessage_Is_Buildable_With_Stubbed_Operations() + { + var operations = new Mock(); + var info = new QueueMessageInfo("1", "r", 1, null, null, null); + var message = new QueueMessage(info, BinaryData.FromString(ValidJson), new TestObject { Property1 = "x" }, null, operations.Object); + + await message.CompleteAsync(); + + operations.Verify(x => x.DeleteAsync("1", "r", It.IsAny()), Times.Once); + message.Settlement.Should().Be(MessageSettlement.Completed); + } +}