From b0ef7d3d205d2c3d79b7b0e97de21ca753d18e25 Mon Sep 17 00:00:00 2001 From: Jason Shave Date: Thu, 15 Oct 2026 08:03:08 -0600 Subject: [PATCH 1/3] Make message receive resilient, fix factory/serialization bugs, bump dependencies (2.1.0) ReceiveMessagesAsync no longer throws for a failed read, a throwing handler, a failed delete, or a throwing exception handler; everything is logged and routed to the caller's exception handler and the rest of the batch is still processed. Fixes the customer-reported host crash. Also: per-service-collection settings via named options (was a process-wide static), thread-safe factory with async queue creation on first use, keyed service injection, IAzureStorageQueueClient, per-client VisibilityTimeout / SerializerOptions / ClientOptions, serializer options honoured on send, trace event timestamps, process_success tag, richer exceptions and logs, README samples that compile, Azure.Identity dropped, dependencies bumped, AutoFixture/FakeItEasy removed from tests (vulnerable transitive deps). Co-Authored-By: Claude Fable 5.1 --- .github/workflows/dotnet.yml | 82 ++--- .github/workflows/nuget.yml | 78 ++--- README.md | 115 +++++-- .../AzureStorage.QueueService.csproj | 13 +- .../AzureStorageQueueClient.cs | 300 ++++++++++++++---- .../DeserializationException.cs | 12 +- .../IAzureStorageQueueClient.cs | 34 ++ .../IQueueClientBuilder.cs | 10 +- .../JsonQueueMessageConverter.cs | 52 ++- .../LoggerMessageExtensions.cs | 29 +- .../QueueClientBuilder.cs | 43 +-- .../QueueClientFactory.cs | 77 +++-- .../QueueClientSettings.cs | 36 ++- .../QueueClientSettingsBuilder.cs | 67 +++- .../QueueClientSettingsRegistry.cs | 16 +- .../ServiceCollectionExtensions.cs | 49 ++- .../Telemetry/MetricExtensions.cs | 5 +- .../Telemetry/QueueServiceDiagnostics.cs | 10 +- ...torage.QueueClient.IntegrationTests.csproj | 10 +- .../AzureStorage.QueueService.Tests.csproj | 15 +- .../AzureStorageQueueServiceTests.cs | 265 ++++++++++------ .../JsonQueueMessageConverterTests.cs | 59 +++- .../QueueClientBuilderTests.cs | 49 ++- .../QueueClientFactoryTests.cs | 257 +++++++-------- 24 files changed, 1115 insertions(+), 568 deletions(-) create mode 100644 src/AzureStorage.QueueService/IAzureStorageQueueClient.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 9161fc1..970d11d 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,41 +1,41 @@ -name: .NET - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - - runs-on: ubuntu-latest - - services: - # Azurite emulates Azure Storage so the integration tests run without a - # live storage account. The official image binds to 0.0.0.0, so the - # mapped ports are reachable from the runner at 127.0.0.1. - azurite: - image: mcr.microsoft.com/azure-storage/azurite - ports: - - 10000:10000 - - 10001:10001 - - 10002:10002 - - steps: - - uses: actions/checkout@v3 - - name: Setup .NET - uses: actions/setup-dotnet@v3 - 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 + + services: + # Azurite emulates Azure Storage so the integration tests run without a + # live storage account. The official image binds to 0.0.0.0, so the + # mapped ports are reachable from the runner at 127.0.0.1. + azurite: + image: mcr.microsoft.com/azure-storage/azurite + ports: + - 10000:10000 + - 10001:10001 + - 10002:10002 + + 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 + - 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 diff --git a/.github/workflows/nuget.yml b/.github/workflows/nuget.yml index 2d7a1a7..7a174cc 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@v3 - - name: Setup .NET - uses: actions/setup-dotnet@v3 - 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: | + 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 diff --git a/README.md b/README.md index a727d8c..9261565 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ You will need to create an Azure Storage account in the Azure portal using a uni - 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. @@ -32,7 +35,8 @@ You will need to create an Azure Storage account in the Azure portal using a uni "QueueClientSettings" : { "ConnectionString": "[your_connection_string]", "QueueName": "[your_queue_name]", - "CreateIfNotExists": true + "CreateIfNotExists": true, + "VisibilityTimeout": "00:02:00" } } ``` @@ -56,7 +60,20 @@ You will need to create an Azure Storage account in the Azure portal using a uni })); ``` -4. You can create your queue in advance or allow the library to create it during runtime by setting the `CreateIfNotExists` property to `true`. +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. + +### Per-client settings + +Every client has its own `QueueClientSettings`: + +| 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 @@ -98,6 +115,10 @@ services.AddAzureStorageQueueClient(x => // get configuration from IConfiguration binder services.AddAzureStorageQueueClient(x => x.AddClient("MyClient1", y => Configuration.Bind(nameof(QueueClientSettings), y))); + +// or bind a configuration section directly +services.AddAzureStorageQueueClient(x => + x.AddClient("MyClient1", Configuration.GetSection("Queues:MyClient1"))); ``` ```csharp @@ -134,7 +155,7 @@ public class MyClass ### Example 2: Get a named client ```csharp -// inject the IQueueClientFactory and get a default client +// inject the IQueueClientFactory and get a named client public class MyClass { private readonly AzureStorageQueueClient _queueClient; @@ -146,26 +167,43 @@ public class MyClass } ``` +### Example 3: Inject a client directly + +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. + +```csharp +public class MyClass +{ + public MyClass( + IAzureStorageQueueClient defaultClient, + [FromKeyedServices("MyClient1")] IAzureStorageQueueClient namedClient) + { + } +} +``` + +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 the class uses the `IHostedService` interface to send a message every five seconds. +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 : IHostedService + public class Sender : BackgroundService { private readonly AzureStorageQueueClient _queueClient; public Sender(IQueueClientFactory queueClientFactory) => _queueClient = queueClientFactory.GetQueueClient(); - public async Task StartAsync(CancellationToken cancellationToken) + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - while (!cancellationToken.IsCancellationRequested) + while (!stoppingToken.IsCancellationRequested) { var myMessage = new MyMessage("Test"); - await _queueClient.SendMessageAsync(myMessage, cancellationToken); - await Task.Delay(5000); + await _queueClient.SendMessageAsync(myMessage, cancellationToken: stoppingToken); + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); } } } @@ -173,32 +211,48 @@ The following example shows the .NET Worker Service template where the class use ## Receiving and handling messages from an Azure storage account queue -The following example shows the .NET Worker Service template where the class uses the `IHostedService` interface to run a particular code block repeatedly. The application will receive the payload from the queue repeatedly. +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. 1. Inject the `IQueueClientFactory` interface and use as follows: ```csharp - public class MySubscriber : IHostedService + public class MySubscriber : BackgroundService { private readonly AzureStorageQueueClient _queueClient; - private readonly IMyMessageHandler _myMessageHandler; // see optional handler below + private readonly IMyMessageHandler _myMessageHandler; // your own handler + private readonly ILogger _logger; - public MySubscriber(IQueueClientFactory queueClientFactory, IMyMessageHandler myMessageHandler) + public MySubscriber(IQueueClientFactory queueClientFactory, IMyMessageHandler myMessageHandler, ILogger logger) { // get the default queue client _queueClient = queueClientFactory.GetQueueClient(); _myMessageHandler = myMessageHandler; + _logger = logger; } - public async Task StartAsync(CancellationToken cancellationToken) + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - while (!cancellationToken.IsCancellationRequested) + while (!stoppingToken.IsCancellationRequested) { - await _queueClient.ReceiveMessagesAsync( - message => _myMessageHandler.HandleAsync(message), - exception => _myMessageHandler.HandleExceptionAsync(exception), - cancellationToken); - await Task.Delay(1000); + try + { + await _queueClient.ReceiveMessagesAsync( + (message, metadata) => _myMessageHandler.HandleAsync(message), + (exception, metadata) => _myMessageHandler.HandleExceptionAsync(exception), + cancellationToken: stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error while polling the queue."); + } + + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); } } } @@ -206,10 +260,10 @@ The following example shows the .NET Worker Service template where the class use ### Handling multiple messages -The library allows you to pull multiple messages by specifying the `maxMessage` count as an integer in the `ReceiveMessagesAsync()` method. These are sent to the handler as individual messages but pulled from the queue as a batch the consuming application would hold a lock on for the default duration used in the Azure Storage Queue library. +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. ```csharp -await _queueClient.ReceiveMessagesAsync(HandleMessage, HandleException, cancellationToken, 10); +await _queueClient.ReceiveMessagesAsync(HandleMessage, HandleException, numMessages: 10, cancellationToken: stoppingToken); ``` ## OpenTelemetry Support @@ -240,7 +294,22 @@ The following metrics have also been configured and read through tools like Azur - `queue_messages_received_total` - `queue_messages_sent_total` - `queue_messages_processed_total` -- `queue_message_processing_duration_seconds` +- `queue_messages_failed_total` +- `queue_message_processing_duration_seconds` (tagged with `queue.process_success`) +- `queue_message_size_bytes` + +All metrics are tagged with `queue.name`, so clients for different queues can be told apart. + +## Upgrading to 2.1 + +No code changes are required, but note these behaviour changes: + +- `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. ## License diff --git a/src/AzureStorage.QueueService/AzureStorage.QueueService.csproj b/src/AzureStorage.QueueService/AzureStorage.QueueService.csproj index 5a1f981..bf91e2e 100644 --- a/src/AzureStorage.QueueService/AzureStorage.QueueService.csproj +++ b/src/AzureStorage.QueueService/AzureStorage.QueueService.csproj @@ -24,13 +24,12 @@ - - - - - - - + + + + + + diff --git a/src/AzureStorage.QueueService/AzureStorageQueueClient.cs b/src/AzureStorage.QueueService/AzureStorageQueueClient.cs index fa2c929..5f160fb 100644 --- a/src/AzureStorage.QueueService/AzureStorageQueueClient.cs +++ b/src/AzureStorage.QueueService/AzureStorageQueueClient.cs @@ -7,104 +7,222 @@ namespace AzureStorage.QueueService; -public class AzureStorageQueueClient +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, + IMessageConverter messageConverter, + QueueClient queueClient, ILoggerFactory loggerFactory, - IOptions telemetrySettingsOptions) + IOptions telemetrySettingsOptions, + TimeSpan? visibilityTimeout = null, + bool createIfNotExists = false) { _messageConverter = messageConverter; _queueClient = queueClient; _logger = loggerFactory.CreateLogger(); _telemetrySettings = telemetrySettingsOptions.Value; + _visibilityTimeout = visibilityTimeout; + _createIfNotExists = createIfNotExists; } - public async ValueTask CreateQueueIfNotExistsAsync(IDictionary? metadata = null, CancellationToken cancellationToken = default) => + 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) => + public async ValueTask ClearMessagesAsync(CancellationToken cancellationToken = default) + { + await EnsureQueueExistsAsync(cancellationToken); await _queueClient.ClearMessagesAsync(cancellationToken); + } - public async ValueTask> PeekMessagesAsync(int numMessages, CancellationToken cancellationToken = default) => - (await _queueClient.PeekMessagesAsync(numMessages, cancellationToken)).Value.Convert(_messageConverter); + 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) => - _queueClient.PeekMessages(numMessages, cancellationToken).Value.Convert(_messageConverter); + public IEnumerable PeekMessages(int numMessages, CancellationToken cancellationToken = default) + { + EnsureQueueExists(cancellationToken); + return _queueClient.PeekMessages(numMessages, cancellationToken).Value.Convert(_messageConverter); + } /// - /// Receives a message of the type specified and deserializes the input using a JSON message converter. - /// The message is processed using a function delegate and errors are processed using an exception handling delegate. + /// 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. /// - /// - /// - /// - /// - /// - /// - /// - public async ValueTask ReceiveMessagesAsync(Func?, ValueTask> handleMessage, Func?, ValueTask> handleException, int numMessages = 1, CancellationToken cancellationToken = default) + /// + /// 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, _queueClient.Name); + tags.Add(QueueServiceDiagnostics.Names.QueueName, QueueName); tags.Add(QueueServiceDiagnostics.Names.QueueMessageCount, numMessages); }) : null; - QueueMessage[] receivedMessages = await _queueClient.ReceiveMessagesAsync(numMessages, null, cancellationToken); + 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); - if (receivedMessages.Any()) + foreach (var queueMessage in receivedMessages) { - _logger.LogMessageCount(receivedMessages.Length); - activity?.AddEvent(QueueServiceDiagnostics.Events.QueueMessageReceived); + cancellationToken.ThrowIfCancellationRequested(); + await ProcessMessageAsync(queueMessage, metadata, handleMessage, handleException, activity, cancellationToken); + } + } - var queueProperties = await _queueClient.GetPropertiesAsync(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), + }; - foreach (var queueMessage in receivedMessages) - { - // Tags common to all metrics for this message - var tagList = new TagList() - { - new(QueueServiceDiagnostics.Names.QueueName, _queueClient.Name), - }; - - // 1) Increment "messages received" count - QueueServiceDiagnostics.Metrics.MessagesReceived.Add(1, tagList); - - // 2) Measure how long it takes to process + handle failures - await QueueServiceDiagnostics.Metrics.MessageProcessingDuration.ProcessWithMetricsAsync( - async () => - { - var convertedMessage = _messageConverter.Convert(queueMessage.MessageText); - await handleMessage(convertedMessage, queueProperties?.Value.Metadata); - - _logger.LogProcessedMessage(queueMessage.MessageId); - await _queueClient.DeleteMessageAsync(queueMessage.MessageId, queueMessage.PopReceipt, cancellationToken); - - // 3) Add an "message processed" event for your Activity - activity?.AddEvent(QueueServiceDiagnostics.Events.QueueMessageProcessed); - - // 4) Increment "messages processed" count - QueueServiceDiagnostics.Metrics.MessagesProcessed.Add(1, tagList); - }, - tagList, - onFailure: async ex => - { - // Custom error logging / handling - activity?.AddException(ex); - await handleException(ex, queueProperties?.Value.Metadata); - } - ); - } + 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; } } @@ -113,33 +231,77 @@ await QueueServiceDiagnostics.Metrics.MessageProcessingDuration.ProcessWithMetri /// /// /// + /// + /// /// /// - /// + /// 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, _queueClient.Name); + 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); - QueueServiceDiagnostics.Metrics.MessagesSent.Add(1, new TagList() + + var tags = new TagList { - new(QueueServiceDiagnostics.Names.QueueName, _queueClient.Name), - }); + 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.LogSendError(e.Message); + _logger.LogSendFailed(QueueName, e); + activity?.AddException(e); throw; } } -} \ No newline at end of file + + // 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/DeserializationException.cs b/src/AzureStorage.QueueService/DeserializationException.cs index 3f61c89..7ffbdfb 100644 --- a/src/AzureStorage.QueueService/DeserializationException.cs +++ b/src/AzureStorage.QueueService/DeserializationException.cs @@ -1,10 +1,18 @@ namespace AzureStorage.QueueService; +/// +/// Thrown when a queue message cannot be deserialized into the requested type. The original serializer error +/// is available as . +/// public class DeserializationException : Exception { public DeserializationException(string message) - : base(message) + : base(message) { + } + public DeserializationException(string message, Exception innerException) + : base(message, innerException) + { } -} \ No newline at end of file +} diff --git a/src/AzureStorage.QueueService/IAzureStorageQueueClient.cs b/src/AzureStorage.QueueService/IAzureStorageQueueClient.cs new file mode 100644 index 0000000..cab143b --- /dev/null +++ b/src/AzureStorage.QueueService/IAzureStorageQueueClient.cs @@ -0,0 +1,34 @@ +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/IQueueClientBuilder.cs b/src/AzureStorage.QueueService/IQueueClientBuilder.cs index 2cc446a..01cb4e5 100644 --- a/src/AzureStorage.QueueService/IQueueClientBuilder.cs +++ b/src/AzureStorage.QueueService/IQueueClientBuilder.cs @@ -4,7 +4,9 @@ namespace AzureStorage.QueueService; internal interface IQueueClientBuilder { - QueueClient CreateClient(QueueClientSettings settings); - - Task CreateClientAsync(QueueClientSettings settings); -} \ No newline at end of file + /// + /// 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); +} diff --git a/src/AzureStorage.QueueService/JsonQueueMessageConverter.cs b/src/AzureStorage.QueueService/JsonQueueMessageConverter.cs index e3a17fd..b2331ce 100644 --- a/src/AzureStorage.QueueService/JsonQueueMessageConverter.cs +++ b/src/AzureStorage.QueueService/JsonQueueMessageConverter.cs @@ -16,43 +16,61 @@ public JsonQueueMessageConverter(JsonSerializerOptions? serializerOptions = null { try { - var convertedMessage = JsonSerializer.Deserialize(input, _serializerOptions); - return convertedMessage; + return JsonSerializer.Deserialize(input, _serializerOptions); } catch (Exception e) { - throw new DeserializationException(e.Message); + throw new DeserializationException(Describe(e), e); } } public TOutput? Convert(string input) { - if (IsBase64(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 { - var byteData = System.Convert.FromBase64String(input); - input = Encoding.UTF8.GetString(byteData); + return JsonSerializer.Deserialize(input, _serializerOptions); } - - try + catch (JsonException) when (TryDecodeBase64(input, out var decoded)) { - var convertedMessage = JsonSerializer.Deserialize(input, _serializerOptions); - return convertedMessage; + try + { + return JsonSerializer.Deserialize(decoded, _serializerOptions); + } + catch (Exception e) + { + throw new DeserializationException(Describe(e), e); + } } catch (Exception e) { - throw new DeserializationException(e.Message); + throw new DeserializationException(Describe(e), e); } } public BinaryData Convert(TInput input) { - var binaryData = new BinaryData(input); - return binaryData; + // Honour the caller's serializer options on the way out as well as on the way in. + return BinaryData.FromObjectAsJson(input, _serializerOptions); } - private static bool IsBase64(string input) + 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) { - Span buffer = new Span(new byte[input.Length]); - return System.Convert.TryFromBase64String(input, buffer, out _); + // 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; } -} \ No newline at end of file +} diff --git a/src/AzureStorage.QueueService/LoggerMessageExtensions.cs b/src/AzureStorage.QueueService/LoggerMessageExtensions.cs index f3b7e01..cc871f8 100644 --- a/src/AzureStorage.QueueService/LoggerMessageExtensions.cs +++ b/src/AzureStorage.QueueService/LoggerMessageExtensions.cs @@ -4,12 +4,39 @@ namespace AzureStorage.QueueService; public 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); + [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); -} \ No newline at end of file + + [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); + + [LoggerMessage(4, LogLevel.Information, "Queue '{QueueName}': processing {MessageCount} message(s).")] + public static partial void LogMessagesReceived(this ILogger logger, string? queueName, int messageCount); + + [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.")] + 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.")] + 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(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); +} diff --git a/src/AzureStorage.QueueService/QueueClientBuilder.cs b/src/AzureStorage.QueueService/QueueClientBuilder.cs index cc54a22..f7b14de 100644 --- a/src/AzureStorage.QueueService/QueueClientBuilder.cs +++ b/src/AzureStorage.QueueService/QueueClientBuilder.cs @@ -4,38 +4,21 @@ namespace AzureStorage.QueueService; internal class QueueClientBuilder : IQueueClientBuilder { - public QueueClient CreateClient(QueueClientSettings settings) + public QueueClient CreateClient(string clientDescription, QueueClientSettings settings) { - var client = Create(settings); + if (!string.IsNullOrEmpty(settings.ConnectionString)) + { + if (string.IsNullOrEmpty(settings.QueueName)) + throw new ApplicationException($"{clientDescription} has a ConnectionString but no QueueName."); - if (settings.CreateIfNotExists) client.CreateIfNotExists(); + return new QueueClient(settings.ConnectionString, settings.QueueName, settings.ClientOptions); + } - return client; - } - - public async Task CreateClientAsync(QueueClientSettings settings) - { - var client = Create(settings); - - if (settings.CreateIfNotExists) await client.CreateIfNotExistsAsync(); - - return client; - } - - private static QueueClient Create(QueueClientSettings settings) - { - QueueClient? client = default; - if (settings.TokenCredential is not null && settings.EndpointUri is not null) - client = new QueueClient(settings.EndpointUri, settings.TokenCredential); - - if (settings.ConnectionString is not null) - client = new QueueClient(settings.ConnectionString, settings.QueueName); - - if (client is null) - throw new ApplicationException( - "There was a problem creating the client. Unable to determine if the endpoint URI or connection string should be used. " + - "Please be sure to set the connection string or the token credential and endpoint URI together."); + if (settings.EndpointUri is not null && settings.TokenCredential is not null) + return new QueueClient(settings.EndpointUri, settings.TokenCredential, settings.ClientOptions); - return client; + throw new ApplicationException( + $"{clientDescription} is not configured. Set ConnectionString and QueueName, " + + "or set EndpointUri (including the queue name) and TokenCredential."); } -} \ No newline at end of file +} diff --git a/src/AzureStorage.QueueService/QueueClientFactory.cs b/src/AzureStorage.QueueService/QueueClientFactory.cs index 1018622..a7010df 100644 --- a/src/AzureStorage.QueueService/QueueClientFactory.cs +++ b/src/AzureStorage.QueueService/QueueClientFactory.cs @@ -1,4 +1,5 @@ -using AzureStorage.QueueService.Telemetry; +using System.Collections.Concurrent; +using AzureStorage.QueueService.Telemetry; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -6,22 +7,25 @@ namespace AzureStorage.QueueService; internal sealed class QueueClientFactory : IQueueClientFactory { - private readonly Dictionary _namedClients = new(); - private AzureStorageQueueClient? _defaultClient; + // 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 QueueClientSettingsRegistry _registry; + private readonly IOptionsMonitor _settings; private readonly IQueueClientBuilder _queueClientBuilder; private readonly ILoggerFactory _loggerFactory; private readonly IMessageConverter _messageConverter; private readonly IOptions _telemetrySettings; public QueueClientFactory( - QueueClientSettingsRegistry registry, - IQueueClientBuilder queueClientBuilder, - ILoggerFactory loggerFactory, + QueueClientSettingsRegistry registry, + IOptionsMonitor settings, + IQueueClientBuilder queueClientBuilder, + ILoggerFactory loggerFactory, IMessageConverter messageConverter, IOptions telemetrySettingsOptions) { _registry = registry; + _settings = settings; _queueClientBuilder = queueClientBuilder; _loggerFactory = loggerFactory; _messageConverter = messageConverter; @@ -30,38 +34,53 @@ public QueueClientFactory( public AzureStorageQueueClient GetQueueClient(string clientName) { - // try named client - _namedClients.TryGetValue(clientName, out var azureStorageQueueClient); - if (azureStorageQueueClient is not null) - { - return azureStorageQueueClient; - } + ArgumentNullException.ThrowIfNull(clientName); - // not found so create one and add it - _registry.NamedClientsSettings.TryGetValue(clientName, out var customClientSettings); - if (customClientSettings is null) + if (!_registry.Contains(clientName)) { - throw new ApplicationException($"Settings for named client, {clientName} not found."); - } + var registered = _registry.ClientNames.Count == 0 + ? "none" + : string.Join(", ", _registry.ClientNames.Select(name => $"'{name}'")); - var client = Create(customClientSettings); - _namedClients.TryAdd(clientName, client); + throw new ApplicationException( + $"No queue client named '{clientName}' has been registered (registered named clients: {registered}). " + + $"Register it with services.AddAzureStorageQueueClient(x => x.AddClient(\"{clientName}\", ...))."); + } - return client; + return GetOrCreate(clientName); } public AzureStorageQueueClient GetQueueClient() { - // use default client - if (_defaultClient is not null) return _defaultClient; - _defaultClient = Create(_registry.DefaultClientSettings); - return _defaultClient; + if (!_registry.HasDefaultClient) + { + throw new ApplicationException( + "No default queue client has been registered. Register one with " + + "services.AddAzureStorageQueueClient(x => x.AddDefaultClient(...)), or ask for a named client with GetQueueClient(name)."); + } + + return GetOrCreate(Options.DefaultName); } - private AzureStorageQueueClient Create(QueueClientSettings settings) + private AzureStorageQueueClient GetOrCreate(string clientName) => + _clients.GetOrAdd(clientName, name => new Lazy(() => Create(name), LazyThreadSafetyMode.ExecutionAndPublication)).Value; + + private AzureStorageQueueClient Create(string clientName) { - var queueClient = _queueClientBuilder.CreateClient(settings); - var azureStorageQueueClient = new AzureStorageQueueClient(_messageConverter, queueClient, _loggerFactory, _telemetrySettings); - return azureStorageQueueClient; + var settings = _settings.Get(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); + + return new AzureStorageQueueClient( + messageConverter, + queueClient, + _loggerFactory, + _telemetrySettings, + settings.VisibilityTimeout, + settings.CreateIfNotExists); } -} \ No newline at end of file +} diff --git a/src/AzureStorage.QueueService/QueueClientSettings.cs b/src/AzureStorage.QueueService/QueueClientSettings.cs index 97f33be..43d0c07 100644 --- a/src/AzureStorage.QueueService/QueueClientSettings.cs +++ b/src/AzureStorage.QueueService/QueueClientSettings.cs @@ -1,5 +1,7 @@ using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using Azure.Core; +using Azure.Storage.Queues; namespace AzureStorage.QueueService; @@ -8,13 +10,43 @@ public class QueueClientSettings { public const string SectionName = "AzureStorageQueueClientSettings"; + /// + /// The storage account connection string. Use this together with , + /// or use and instead. + /// public string? ConnectionString { get; set; } public string? QueueName { get; set; } + /// + /// When true, the queue is created the first time the client is used if it does not already exist. + /// public bool CreateIfNotExists { get; set; } - + + /// + /// The full queue URI, including the queue name. Use this together with . + /// public Uri? EndpointUri { get; set; } public TokenCredential? TokenCredential { get; set; } -} \ No newline at end of file + + /// + /// 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. + /// + 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. + /// + public JsonSerializerOptions? SerializerOptions { 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; } +} diff --git a/src/AzureStorage.QueueService/QueueClientSettingsBuilder.cs b/src/AzureStorage.QueueService/QueueClientSettingsBuilder.cs index 2c776e0..d79249d 100644 --- a/src/AzureStorage.QueueService/QueueClientSettingsBuilder.cs +++ b/src/AzureStorage.QueueService/QueueClientSettingsBuilder.cs @@ -1,27 +1,74 @@ -namespace AzureStorage.QueueService; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace AzureStorage.QueueService; public class QueueClientSettingsBuilder { + internal IServiceCollection Services { get; } + internal QueueClientSettingsRegistry Registry { get; } - internal QueueClientSettingsBuilder(QueueClientSettingsRegistry registry) + internal QueueClientSettingsBuilder(IServiceCollection services, QueueClientSettingsRegistry registry) { + Services = services; Registry = registry; } - public QueueClientSettingsBuilder AddClient(string clientName, Action settings) + /// + /// 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. + /// + public QueueClientSettingsBuilder AddClient(string clientName, Action configure) { - var queueClientSettings = new QueueClientSettings(); - settings(queueClientSettings); + ArgumentException.ThrowIfNullOrEmpty(clientName); + ArgumentNullException.ThrowIfNull(configure); - Registry.NamedClientsSettings.TryAdd(clientName, queueClientSettings); + 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; } - public void AddDefaultClient(Action settings) + /// + /// Registers a named client bound from a configuration section, for example + /// configuration.GetSection("Queues:Orders"). + /// + public QueueClientSettingsBuilder 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. + /// + public void 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()); + } + + /// + /// Registers the default client bound from a configuration section. + /// + public void AddDefaultClient(IConfiguration configuration) { - Registry.DefaultClientSettings = new QueueClientSettings(); - settings(Registry.DefaultClientSettings); + ArgumentNullException.ThrowIfNull(configuration); + AddDefaultClient(settings => configuration.Bind(settings)); } -} \ No newline at end of file +} diff --git a/src/AzureStorage.QueueService/QueueClientSettingsRegistry.cs b/src/AzureStorage.QueueService/QueueClientSettingsRegistry.cs index a917f37..127263d 100644 --- a/src/AzureStorage.QueueService/QueueClientSettingsRegistry.cs +++ b/src/AzureStorage.QueueService/QueueClientSettingsRegistry.cs @@ -1,8 +1,18 @@ namespace AzureStorage.QueueService; +/// +/// Records which client names were registered. The settings themselves live in the named-options system +/// (IOptionsMonitor<QueueClientSettings>), keyed by the same names. +/// internal sealed class QueueClientSettingsRegistry { - public Dictionary NamedClientsSettings { get; } = new(); + private readonly HashSet _clientNames = new(StringComparer.Ordinal); - public QueueClientSettings DefaultClientSettings { get; set; } = new(); -} \ No newline at end of file + public bool HasDefaultClient { get; set; } + + public IReadOnlyCollection ClientNames => _clientNames; + + public void Add(string clientName) => _clientNames.Add(clientName); + + public bool Contains(string clientName) => _clientNames.Contains(clientName); +} diff --git a/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs b/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs index ca481bb..22478c3 100644 --- a/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs +++ b/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs @@ -1,22 +1,36 @@ using System.Text.Json; using AzureStorage.QueueService.Telemetry; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace AzureStorage.QueueService; public static class ServiceCollectionExtensions { - private static readonly QueueClientSettingsRegistry Registry = new(); - - public static IServiceCollection AddAzureStorageQueueClient(this IServiceCollection services, Action azureStorageQueueClientBuilderDelegate, JsonSerializerOptions? serializerOptions = null) + /// + /// Registers the queue client factory and the clients configured in . + /// 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. + /// + public static IServiceCollection AddAzureStorageQueueClient(this IServiceCollection services, Action configure, JsonSerializerOptions? serializerOptions = null) { - var builder = new QueueClientSettingsBuilder(Registry); - azureStorageQueueClientBuilderDelegate(builder); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + var registry = GetOrAddRegistry(services); + + services.TryAddSingleton(); + services.TryAddSingleton(); + + if (serializerOptions is not null) + services.Replace(ServiceDescriptor.Singleton(new JsonQueueMessageConverter(serializerOptions))); + else + services.TryAddSingleton(new JsonQueueMessageConverter()); - services.AddSingleton(Registry); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(new JsonQueueMessageConverter(serializerOptions)); + configure(new QueueClientSettingsBuilder(services, registry)); return services; } @@ -26,4 +40,19 @@ public static IServiceCollection ConfigureQueueServiceTelemetry(this IServiceCol services.Configure(options); return services; } -} \ No newline at end of file + + // 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. + private static QueueClientSettingsRegistry GetOrAddRegistry(IServiceCollection services) + { + foreach (var descriptor in services) + { + if (descriptor.ServiceType == typeof(QueueClientSettingsRegistry) && descriptor.ImplementationInstance is QueueClientSettingsRegistry existing) + return existing; + } + + var registry = new QueueClientSettingsRegistry(); + services.AddSingleton(registry); + return registry; + } +} diff --git a/src/AzureStorage.QueueService/Telemetry/MetricExtensions.cs b/src/AzureStorage.QueueService/Telemetry/MetricExtensions.cs index 95d5cba..f5b6880 100644 --- a/src/AzureStorage.QueueService/Telemetry/MetricExtensions.cs +++ b/src/AzureStorage.QueueService/Telemetry/MetricExtensions.cs @@ -36,10 +36,9 @@ public static async Task ProcessWithMetricsAsync( } finally { - var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; - histogram.Record(elapsedSeconds, tags); - + // 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 99d0a75..828680e 100644 --- a/src/AzureStorage.QueueService/Telemetry/QueueServiceDiagnostics.cs +++ b/src/AzureStorage.QueueService/Telemetry/QueueServiceDiagnostics.cs @@ -6,7 +6,7 @@ namespace AzureStorage.QueueService.Telemetry; internal class QueueServiceDiagnostics { public static string ServiceName { get; } = "AzureStorage.QueueService"; - + public static ActivitySource ActivitySource { get; } = new ActivitySource(ServiceName); public static Meter Meter { get; } = new Meter(ServiceName); @@ -64,9 +64,11 @@ public struct Metrics ); } + // Event names only. An ActivityEvent captures its timestamp when constructed, so a shared static instance + // would stamp every event with the time the process started. public struct Events - { - public static ActivityEvent QueueMessageReceived = new ActivityEvent("queue.message_received"); - public static ActivityEvent QueueMessageProcessed = new ActivityEvent("queue.message_processed"); + { + public const string QueueMessageReceived = "queue.message_received"; + public const string QueueMessageProcessed = "queue.message_processed"; } } diff --git a/tests/AzureStorage.QueueClient.IntegrationTests/AzureStorage.QueueClient.IntegrationTests.csproj b/tests/AzureStorage.QueueClient.IntegrationTests/AzureStorage.QueueClient.IntegrationTests.csproj index d92f80d..f03f179 100644 --- a/tests/AzureStorage.QueueClient.IntegrationTests/AzureStorage.QueueClient.IntegrationTests.csproj +++ b/tests/AzureStorage.QueueClient.IntegrationTests/AzureStorage.QueueClient.IntegrationTests.csproj @@ -10,12 +10,12 @@ - - - - + + + + - + diff --git a/tests/AzureStorage.QueueService.Tests/AzureStorage.QueueService.Tests.csproj b/tests/AzureStorage.QueueService.Tests/AzureStorage.QueueService.Tests.csproj index d864feb..23316d8 100644 --- a/tests/AzureStorage.QueueService.Tests/AzureStorage.QueueService.Tests.csproj +++ b/tests/AzureStorage.QueueService.Tests/AzureStorage.QueueService.Tests.csproj @@ -9,18 +9,17 @@ - - - - - - + + + + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/AzureStorage.QueueService.Tests/AzureStorageQueueServiceTests.cs b/tests/AzureStorage.QueueService.Tests/AzureStorageQueueServiceTests.cs index 3150de4..3bfa41b 100644 --- a/tests/AzureStorage.QueueService.Tests/AzureStorageQueueServiceTests.cs +++ b/tests/AzureStorage.QueueService.Tests/AzureStorageQueueServiceTests.cs @@ -1,11 +1,8 @@ -using AutoFixture; -using Azure; -using Azure.Messaging; +using Azure; using Azure.Storage.Queues; using Azure.Storage.Queues.Models; using AzureStorage.QueueService.Telemetry; using FluentAssertions; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -13,144 +10,208 @@ namespace AzureStorage.QueueService.Tests; -public class AzureStorageQueueServiceTests : BaseTestHost +public class AzureStorageQueueServiceTests { - private readonly QueueClientSettings _queueClientSettings; - private readonly IOptions _telemetrySettings = Options.Create(new QueueServiceTelemetrySettings()); - private readonly IServiceProvider _serviceProvider; + 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}"; - public AzureStorageQueueServiceTests() + private static Mock QueueClientReturning(params string[] messageIds) { - var services = new ServiceCollection(); - services.AddLogging(); - - _serviceProvider = services.BuildServiceProvider(); - - _queueClientSettings = new(); - Configuration.Bind(nameof(QueueClientSettings), _queueClientSettings); - - var data = new TestObject - { - Property1 = "test", - Property2 = true, - Property3 = 1 - }; - var cloudEvent = new CloudEvent("source", "myClass", data, typeof(TestObject)) - { - Id = "myCustomId", - }; + 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 fixture = new Fixture(); - Response mockResponse = Mock.Of(); - var peekedMessage = QueuesModelFactory.PeekedMessage("1", "test_text", 1); - PeekedMessage[] peekedMessages = { peekedMessage }; - var response = Response.FromValue(peekedMessages, mockResponse); - + var peekedMessages = new[] { QueuesModelFactory.PeekedMessage("1", ValidJson, 1) }; var mockQueueClient = new Mock(); - mockQueueClient.Setup(x => x.PeekMessagesAsync(It.IsAny(), CancellationToken.None)).ReturnsAsync(response); + mockQueueClient.Setup(x => x.PeekMessagesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(peekedMessages, Mock.Of())); - var mockMessageConverter = new Mock(); - mockMessageConverter.Setup(x => x.Convert(It.IsAny())).Returns(fixture.Create()); + // act + var messages = await CreateSubject(mockQueueClient).PeekMessagesAsync(1); - var loggerFactory = _serviceProvider.GetRequiredService(); + // assert + messages.Should().ContainSingle().Which.Property1.Should().Be("test"); + } - var subject = new AzureStorageQueueClient(mockMessageConverter.Object, mockQueueClient.Object, loggerFactory, _telemetrySettings); + [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 - var messages = await subject.PeekMessagesAsync(It.IsAny()); + await CreateSubject(mockQueueClient).ReceiveMessagesAsync( + (message, _) => { handled.Add(message); return ValueTask.CompletedTask; }, + Ignore, + numMessages: 2); // assert - messages.Should().NotBeEmpty(); - messages.Count().Should().Be(1); + 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 = "Peek messages returns message collection")] - public async Task Receive_Messages_Returns_Collection() + [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 fixture = new Fixture(); - Response mockResponse = Mock.Of(); - var queueMessage = QueuesModelFactory.QueueMessage("1", "2", "test_text", 1); - QueueMessage[] peekedMessages = { queueMessage }; - var response = Response.FromValue(peekedMessages, mockResponse); + 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(1, null, CancellationToken.None)).ReturnsAsync(response); - mockQueueClient.Setup(x => x.DeleteMessageAsync(It.IsAny(), It.IsAny(), CancellationToken.None)); + 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(); - var mockMessageConverter = new Mock(); - mockMessageConverter.Setup(x => x.Convert(It.IsAny())).Returns(fixture.Create()); + // act + Func act = async () => await CreateSubject(mockQueueClient).ReceiveMessagesAsync( + (_, _) => throw new Exception("handler should not run"), + (exception, _) => { reported.Add(exception); return ValueTask.CompletedTask; }); - var loggerFactory = _serviceProvider.GetRequiredService(); + // assert + await act.Should().NotThrowAsync(); + reported.Should().ContainSingle().Which.Should().BeOfType(); + } - var subject = new AzureStorageQueueClient(mockMessageConverter.Object, mockQueueClient.Object, loggerFactory, _telemetrySettings); + [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/assert - await subject.ReceiveMessagesAsync(HandleMessage, HandleException); + // act + Func act = async () => await CreateSubject(mockQueueClient).ReceiveMessagesAsync( + (_, _) => { handlerCalls++; throw new InvalidOperationException("handler failed"); }, + (_, _) => throw new InvalidOperationException("exception handler failed too"), + numMessages: 2); - ValueTask HandleMessage(TestObject? testObject, IDictionary? metadata) => ValueTask.CompletedTask; - ValueTask HandleException(Exception exception, IDictionary? metadata) => ValueTask.CompletedTask; + // assert + await act.Should().NotThrowAsync(); + handlerCalls.Should().Be(2); } - [Fact(DisplayName = "Peek messages returns message collection")] - public async Task Receive_Messages_Throws_Collection() + [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 fixture = new Fixture(); - var mockResponse = Mock.Of(); - var queueMessage = QueuesModelFactory.QueueMessage("1", "2", "test_text", 1); - QueueMessage[] peekedMessages = { queueMessage }; - var response = Response.FromValue(peekedMessages, mockResponse); - - var mockQueueClient = new Mock(_queueClientSettings.ConnectionString!, _queueClientSettings.QueueName!); - mockQueueClient.Setup(x => x.ReceiveMessagesAsync(1, null, CancellationToken.None)).ReturnsAsync(response); - mockQueueClient.Setup(x => x.DeleteMessageAsync(It.IsAny(), It.IsAny(), CancellationToken.None)); - - var mockMessageConverter = new Mock(); - mockMessageConverter.Setup(x => x.Convert(It.IsAny())).Returns(fixture.Create()); - - var loggerFactory = _serviceProvider.GetRequiredService(); - - var subject = new AzureStorageQueueClient(mockMessageConverter.Object, mockQueueClient.Object, loggerFactory, _telemetrySettings); - - // act/assert - await subject.ReceiveMessagesAsync( - (message, metadata) => throw new Exception("Hello from Exception"), - (exception, metadata) => - { - exception.Message.Should().Be("Hello from Exception"); - return ValueTask.CompletedTask; - }); + 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 = "Can send message")] - public async Task Can_Send_Message() + [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 - var fixture = new Fixture(); - var testObject = fixture.Create(); + 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(); + } - var mockQueueClient = new Mock(_queueClientSettings.ConnectionString!, _queueClientSettings.QueueName!); - var mockResponse = Mock.Of(); - var mockMessageConverter = new Mock(); + [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); - mockMessageConverter.Setup(x => x.Convert(testObject)).Returns(It.IsAny()); + // act + await CreateSubject(mockQueueClient, visibilityTimeout).ReceiveMessagesAsync((_, _) => ValueTask.CompletedTask, Ignore); - SendReceipt sendReceipt = - QueuesModelFactory.SendReceipt("1", DateTimeOffset.Now, DateTimeOffset.Now, "2", DateTimeOffset.Now); - var response = Response.FromValue(sendReceipt, mockResponse); - mockQueueClient.Setup(x => x.SendMessageAsync(It.IsAny(), null, null, CancellationToken.None)).ReturnsAsync(response); + // assert + mockQueueClient.Verify(x => x.ReceiveMessagesAsync(1, visibilityTimeout, It.IsAny()), Times.Once); + } - var loggerFactory = _serviceProvider.GetRequiredService(); + [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())); - var subject = new AzureStorageQueueClient(mockMessageConverter.Object, mockQueueClient.Object, loggerFactory, _telemetrySettings); + // act + var receipt = await CreateSubject(mockQueueClient).SendMessageAsync(testObject); - // act/assert - await subject.Invoking(async x => await x.SendMessageAsync(testObject)).Should().NotThrowAsync(); + // assert + receipt.Should().BeSameAs(sendReceipt); + mockQueueClient.Verify(x => x.SendMessageAsync(It.Is(b => b.ToString().Contains("\"Property1\":\"sent\"")), null, null, It.IsAny()), Times.Once); } -} \ No newline at end of file +} diff --git a/tests/AzureStorage.QueueService.Tests/JsonQueueMessageConverterTests.cs b/tests/AzureStorage.QueueService.Tests/JsonQueueMessageConverterTests.cs index 2dde07d..33c0c0f 100644 --- a/tests/AzureStorage.QueueService.Tests/JsonQueueMessageConverterTests.cs +++ b/tests/AzureStorage.QueueService.Tests/JsonQueueMessageConverterTests.cs @@ -1,6 +1,5 @@ using System.Text; using System.Text.Json; -using AutoFixture; using Azure.Storage.Queues.Models; using FluentAssertions; @@ -8,12 +7,13 @@ 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 fixture = new Fixture(); - var testObject = fixture.Create(); + var testObject = NewTestObject(); var json = JsonSerializer.Serialize(testObject); var testObjectBytes = Encoding.UTF8.GetBytes(json); var queueMessage = QueuesModelFactory.QueueMessage("1", "2", new BinaryData(testObjectBytes), 1); @@ -26,38 +26,49 @@ public void Convert_To_Object() testObject.Should().BeEquivalentTo(newTestObject); } - [Fact(DisplayName = "Convert empty string returns null")] - public void Convert_Empty_Binary_Returns_Null() + [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(); + subject.Invoking(x => x.Convert(string.Empty)).Should().Throw() + .WithInnerException(); } - [Fact(DisplayName = "Convert object to BinaryData")] - public void Convert_Object_To_BinaryData() + [Fact(DisplayName = "Convert Base64-encoded JSON (as written by older SDKs and Azure Functions)")] + public void Convert_Base64_Encoded_Json() { // arrange - var fixture = new Fixture(); - var testObject = fixture.Create(); - + var testObject = NewTestObject(); + var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(testObject))); var subject = new JsonQueueMessageConverter(); // act - BinaryData binaryData = subject.Convert(testObject); + var result = subject.Convert(encoded); // assert - binaryData.Should().BeOfType(typeof(BinaryData)); + 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 fixture = new Fixture(); - var testObject = fixture.Create(); + var testObject = NewTestObject(); var subject = new JsonQueueMessageConverter(); @@ -68,4 +79,20 @@ public void Convert_To_And_From() // assert testObject.Should().BeEquivalentTo(convertedTestObject); } -} \ No newline at end of file + + [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/QueueClientBuilderTests.cs b/tests/AzureStorage.QueueService.Tests/QueueClientBuilderTests.cs index 20d68a5..b681644 100644 --- a/tests/AzureStorage.QueueService.Tests/QueueClientBuilderTests.cs +++ b/tests/AzureStorage.QueueService.Tests/QueueClientBuilderTests.cs @@ -5,23 +5,42 @@ namespace AzureStorage.QueueService.Tests; public class QueueClientBuilderTests { + private const string Description = "Queue client 'test'"; + [Fact] - public void CreateClient_Returns_QueueClient() + public void CreateClient_WithEndpointAndCredential_ReturnsQueueClient() { // arrange var settings = new QueueClientSettings() { TokenCredential = new DefaultAzureCredential(), - EndpointUri = new Uri("https://fake.com"), + EndpointUri = new Uri("https://fake.com/queue1"), + }; + var subject = new QueueClientBuilder(); + + // act + var client = subject.CreateClient(Description, settings); + + // assert + client.Name.Should().Be("queue1"); + } + + [Fact] + public void CreateClient_WithConnectionString_ReturnsQueueClient() + { + // arrange + var settings = new QueueClientSettings() + { + ConnectionString = "UseDevelopmentStorage=true", QueueName = "queue1", }; var subject = new QueueClientBuilder(); // act - var client = subject.CreateClient(settings); + var client = subject.CreateClient(Description, settings); // assert - client.Should().NotBeNull(); + client.Name.Should().Be("queue1"); } [Fact] @@ -36,11 +55,25 @@ public void CreateClient_WithoutUri_Throws() var subject = new QueueClientBuilder(); // act & assert - subject.Invoking(x => x.CreateClient(settings)).Should().Throw(); + subject.Invoking(x => x.CreateClient(Description, settings)).Should().Throw().WithMessage($"{Description}*"); + } + + [Fact] + public void CreateClient_WithConnectionStringButNoQueueName_Throws() + { + // arrange + var settings = new QueueClientSettings() + { + ConnectionString = "UseDevelopmentStorage=true", + }; + var subject = new QueueClientBuilder(); + + // act & assert + subject.Invoking(x => x.CreateClient(Description, settings)).Should().Throw().WithMessage("*QueueName*"); } [Fact] - public void CreateClient_WithoutConnectionString_Throws() + public void CreateClient_WithNothingConfigured_Throws() { // arrange var settings = new QueueClientSettings() @@ -50,6 +83,6 @@ public void CreateClient_WithoutConnectionString_Throws() var subject = new QueueClientBuilder(); // act & assert - subject.Invoking(x => x.CreateClient(settings)).Should().Throw(); + subject.Invoking(x => x.CreateClient(Description, settings)).Should().Throw(); } -} \ No newline at end of file +} diff --git a/tests/AzureStorage.QueueService.Tests/QueueClientFactoryTests.cs b/tests/AzureStorage.QueueService.Tests/QueueClientFactoryTests.cs index 610c31b..b370332 100644 --- a/tests/AzureStorage.QueueService.Tests/QueueClientFactoryTests.cs +++ b/tests/AzureStorage.QueueService.Tests/QueueClientFactoryTests.cs @@ -1,205 +1,192 @@ -using Azure.Identity; -using Azure.Storage.Queues; -using AzureStorage.QueueService.Telemetry; -using FakeItEasy; +using Azure.Storage.Queues; using FluentAssertions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Moq; namespace AzureStorage.QueueService.Tests; -public class QueueClientFactoryTests : BaseTestHost +public class QueueClientFactoryTests { - private readonly IOptions _telemetrySettings = Options.Create(new QueueServiceTelemetrySettings()); + private const string ConnectionString = "UseDevelopmentStorage=true"; - [Fact] - public void QueueClientFactory_GetNamedClient_ReturnsClient() + /// + /// 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. + /// + private static (IServiceCollection Services, List<(string Description, QueueClientSettings Settings)> Created) CreateServices() { - // arrange + var created = new List<(string, QueueClientSettings)>(); + var builder = new Mock(); + builder.Setup(x => x.CreateClient(It.IsAny(), It.IsAny())) + .Returns((string description, QueueClientSettings settings) => + { + created.Add((description, settings)); + return new Mock().Object; + }); + var services = new ServiceCollection(); services.AddLogging(); - services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => Configuration.Bind("QueueClientSettings", y))); + services.AddSingleton(builder.Object); // registered first so the library's TryAdd keeps it - var serviceProvider = services.BuildServiceProvider(); + return (services, created); + } - var registry = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - var messageConverter = serviceProvider.GetRequiredService(); - var mockQueueClientBuilder = new Mock(); - var mockQueueClient = new Mock(); - mockQueueClientBuilder.Setup(x => x.CreateClient(It.IsAny())).Returns(mockQueueClient.Object); - IQueueClientFactory subject = new QueueClientFactory(registry, mockQueueClientBuilder.Object, loggerFactory, messageConverter, _telemetrySettings); + [Fact] + public void GetNamedClient_ReturnsSameClientEachTime() + { + // arrange + var (services, created) = CreateServices(); + services.AddAzureStorageQueueClient(x => x.AddClient("foo", y => { y.ConnectionString = ConnectionString; y.QueueName = "foo-queue"; })); + var factory = services.BuildServiceProvider().GetRequiredService(); // act - var queueClient = subject.GetQueueClient("foo"); + var client1 = factory.GetQueueClient("foo"); + var client2 = factory.GetQueueClient("foo"); // assert - queueClient.Should().NotBeNull(); + client1.Should().BeSameAs(client2); + created.Should().ContainSingle().Which.Settings.QueueName.Should().Be("foo-queue"); } [Fact] - public void QueueClientFactory_AddStorageQueueCalledTwice_ReturnsClient() + public void RegisterMultipleNamedClients_EachGetsItsOwnSettings() { // arrange - var services = new ServiceCollection(); - services.AddLogging(); - services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => Configuration.Bind("QueueClientSettings", y))); - services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => Configuration.Bind("QueueClientSettings", y))); - - var serviceProvider = services.BuildServiceProvider(); - - var registry = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - var messageConverter = serviceProvider.GetRequiredService(); - var mockQueueClientBuilder = new Mock(); - var mockQueueClient = new Mock(); - mockQueueClientBuilder.Setup(x => x.CreateClient(It.IsAny())).Returns(mockQueueClient.Object); - IQueueClientFactory subject = new QueueClientFactory(registry, mockQueueClientBuilder.Object, loggerFactory, messageConverter, _telemetrySettings); + 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 queueClient = subject.GetQueueClient("foo"); + var foo = factory.GetQueueClient("foo"); + var bar = factory.GetQueueClient("bar"); // assert - queueClient.Should().NotBeNull(); + foo.Should().NotBeSameAs(bar); + created.Select(c => c.Settings.QueueName).Should().BeEquivalentTo("foo-queue", "bar-queue"); } [Fact] - public void GetNamedClientTwice_ReturnsSameClient() + public void AddClientTwiceForSameName_ComposesConfiguration() { - // arrange - var services = new ServiceCollection(); - services.AddLogging(); - services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => Configuration.Bind("QueueClientSettings", y))); - - var serviceProvider = services.BuildServiceProvider(); - - var registry = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - var messageConverter = serviceProvider.GetRequiredService(); - var mockQueueClientBuilder = new Mock(); - var mockQueueClient = new Mock(); - mockQueueClientBuilder.Setup(x => x.CreateClient(It.IsAny())).Returns(mockQueueClient.Object); - IQueueClientFactory subject = new QueueClientFactory(registry, mockQueueClientBuilder.Object, loggerFactory, messageConverter, _telemetrySettings); + // 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 - var queueClient1 = subject.GetQueueClient("foo"); - var queueClient2 = subject.GetQueueClient("foo"); + factory.GetQueueClient("foo"); // assert - queueClient1.Should().NotBeNull(); - queueClient2.Should().NotBeNull(); - queueClient1.Should().BeSameAs(queueClient2); + var settings = created.Should().ContainSingle().Which.Settings; + settings.ConnectionString.Should().Be(ConnectionString); + settings.QueueName.Should().Be("foo-queue"); } [Fact] - public void RegisterMultipleNamedClient_ReturnsCorrectClient() + public void SeparateServiceCollections_DoNotShareSettings() { // arrange - var services = new ServiceCollection(); - services.AddLogging(); - services.AddAzureStorageQueueClient(options => - { - options.AddClient("foo", y => Configuration.Bind("QueueClientSettings", y)); - options.AddClient("bar", y => Configuration.Bind("QueueClientSettings", y)); - }); - - var serviceProvider = services.BuildServiceProvider(); - - var registry = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - var messageConverter = serviceProvider.GetRequiredService(); - var mockQueueClientBuilder = new Mock(); - var mockQueueClient = new Mock(); - mockQueueClientBuilder.Setup(x => x.CreateClient(It.IsAny())).Returns(mockQueueClient.Object); - IQueueClientFactory subject = new QueueClientFactory(registry, mockQueueClientBuilder.Object, loggerFactory, messageConverter, _telemetrySettings); + 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 - var queueClient1 = subject.GetQueueClient("foo"); - var queueClient2 = subject.GetQueueClient("bar"); + servicesA.BuildServiceProvider().GetRequiredService().GetQueueClient(); + servicesB.BuildServiceProvider().GetRequiredService().GetQueueClient(); // assert - queueClient1.Should().NotBeNull(); - queueClient2.Should().NotBeNull(); + createdA.Should().ContainSingle().Which.Settings.QueueName.Should().Be("queue-a"); + createdB.Should().ContainSingle().Which.Settings.QueueName.Should().Be("queue-b"); } [Fact] - public void GetDefaultClient_ReturnsValidClient() + public void GetUnregisteredClient_ThrowsNamingTheRegisteredClients() { // arrange - var services = new ServiceCollection(); - services.AddLogging(); - services.AddAzureStorageQueueClient(options => options.AddDefaultClient(y => Configuration.Bind("QueueClientSettings", y))); - - var serviceProvider = services.BuildServiceProvider(); + var (services, _) = CreateServices(); + services.AddAzureStorageQueueClient(x => x.AddClient("foo", y => y.ConnectionString = ConnectionString)); + var factory = services.BuildServiceProvider().GetRequiredService(); - var registry = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - var messageConverter = serviceProvider.GetRequiredService(); - var mockQueueClientBuilder = new Mock(); - var mockQueueClient = new Mock(); - mockQueueClientBuilder.Setup(x => x.CreateClient(It.IsAny())).Returns(mockQueueClient.Object); - IQueueClientFactory subject = new QueueClientFactory(registry, mockQueueClientBuilder.Object, loggerFactory, messageConverter, _telemetrySettings); + // act/assert + factory.Invoking(x => x.GetQueueClient("bar")).Should().Throw() + .WithMessage("*'bar'*").WithMessage("*'foo'*"); + } - // act - var queueClient = subject.GetQueueClient(); + [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(); - // assert - queueClient.Should().NotBeNull(); + // act/assert + factory.Invoking(x => x.GetQueueClient()).Should().Throw().WithMessage("*AddDefaultClient*"); } [Fact] - public void GetUnregisteredClient_Throws() + public void Clients_CanBeInjectedDirectly() { // arrange - var services = new ServiceCollection(); - services.AddLogging(); - services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => Configuration.Bind("QueueClientSettings", y))); - - var serviceProvider = services.BuildServiceProvider(); + var (services, _) = CreateServices(); + services.AddAzureStorageQueueClient(x => + { + x.AddClient("foo", y => { y.ConnectionString = ConnectionString; y.QueueName = "foo-queue"; }); + x.AddDefaultClient(y => { y.ConnectionString = ConnectionString; y.QueueName = "default-queue"; }); + }); + 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 registry = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - var messageConverter = serviceProvider.GetRequiredService(); - var mockQueueClientBuilder = new Mock(); - var mockQueueClient = new Mock(); - mockQueueClientBuilder.Setup(x => x.CreateClient(It.IsAny())).Returns(mockQueueClient.Object); - IQueueClientFactory subject = new QueueClientFactory(registry, mockQueueClientBuilder.Object, loggerFactory, messageConverter, _telemetrySettings); + [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", + }).Build(); + var (services, created) = CreateServices(); + services.AddAzureStorageQueueClient(x => x.AddClient("foo", configuration.GetSection("Queues:Foo"))); + var factory = services.BuildServiceProvider().GetRequiredService(); // act - subject.Invoking(x => x.GetQueueClient("bar")).Should().Throw(because: "The client name wasn't registered"); + factory.GetQueueClient("foo"); + + // assert + var settings = created.Should().ContainSingle().Which.Settings; + settings.QueueName.Should().Be("foo-queue"); + settings.VisibilityTimeout.Should().Be(TimeSpan.FromMinutes(5)); } [Fact] - public void CreateClient_WithCorrectSettings_Creates() + public void ConcurrentFirstCalls_CreateOneClient() { // arrange - var services = new ServiceCollection(); - services.AddLogging(); - services.AddAzureStorageQueueClient(options => options.AddDefaultClient(y => - { - y.TokenCredential = new DefaultAzureCredential(); - y.EndpointUri = new Uri("https://fake.com"); - y.QueueName = "foo"; - y.CreateIfNotExists = true; - })); - - var serviceProvider = services.BuildServiceProvider(); - - var registry = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - var messageConverter = serviceProvider.GetRequiredService(); - var mockQueueClientBuilder = new Mock(); - var mockQueueClient = new Mock(); - mockQueueClientBuilder.Setup(x => x.CreateClient(It.IsAny())).Returns(mockQueueClient.Object); - IQueueClientFactory subject = new QueueClientFactory(registry, mockQueueClientBuilder.Object, loggerFactory, messageConverter, _telemetrySettings); + var (services, created) = CreateServices(); + services.AddAzureStorageQueueClient(x => x.AddClient("foo", y => y.ConnectionString = ConnectionString)); + var factory = services.BuildServiceProvider().GetRequiredService(); // act - var client = subject.GetQueueClient(); + var clients = Enumerable.Range(0, 32).AsParallel().Select(_ => factory.GetQueueClient("foo")).ToList(); // assert - client.Should().NotBeNull(); + created.Should().ContainSingle(); + clients.Distinct().Should().ContainSingle(); } -} \ No newline at end of file +} From 3093e086adbd1e03c50cd0184d5db9b72f2d9460 Mon Sep 17 00:00:00 2001 From: Jason Shave Date: Thu, 15 Oct 2026 08:13:37 -0600 Subject: [PATCH 2/3] Use the Unifinity brand mark as the package and README logo Replaces the AI-generated infinity JPEG with the Unifinity mark from the brand kit: icon-256.png as the NuGet package icon, and the dark/light SVG masters in the README via a element so GitHub shows the right colourway for the viewer's theme. Co-Authored-By: Claude Fable 5.1 --- README.md | 7 ++++++- images/logo-dark.svg | 5 +++++ images/logo-light.svg | 5 +++++ images/logo.jpg | Bin 49917 -> 0 bytes images/logo.png | Bin 0 -> 2018 bytes .../AzureStorage.QueueService.csproj | 4 ++-- 6 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 images/logo-dark.svg create mode 100644 images/logo-light.svg delete mode 100644 images/logo.jpg create mode 100644 images/logo.png diff --git a/README.md b/README.md index 9261565..17e5c9f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # Azure Storage Queue Service library -Logo + + + Unifinity + [![.NET](https://github.com/jasonshave/JasonShave.AzureStorage.QueueService/actions/workflows/dotnet.yml/badge.svg)](https://github.com/jasonshave/JasonShave.AzureStorage.QueueService/actions/workflows/dotnet.yml) [![Nuget](https://github.com/jasonshave/JasonShave.AzureStorage.QueueService/actions/workflows/nuget.yml/badge.svg)](https://github.com/jasonshave/JasonShave.AzureStorage.QueueService/actions/workflows/nuget.yml) @@ -314,3 +317,5 @@ No code changes are required, but note these behaviour changes: ## License This project is licensed under the MIT License - see the [LICENSE.md](license.md) file for details. + +The logo is the Unifinity Software Corporation brand mark, taken unchanged from the Unifinity brand kit. It is not covered by the MIT licence. diff --git a/images/logo-dark.svg b/images/logo-dark.svg new file mode 100644 index 0000000..a4d66e2 --- /dev/null +++ b/images/logo-dark.svg @@ -0,0 +1,5 @@ + +Unifinity + + + diff --git a/images/logo-light.svg b/images/logo-light.svg new file mode 100644 index 0000000..b7ca51c --- /dev/null +++ b/images/logo-light.svg @@ -0,0 +1,5 @@ + +Unifinity + + + diff --git a/images/logo.jpg b/images/logo.jpg deleted file mode 100644 index c61df8d45855024cb460c64cf13d32c3678ddcf4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49917 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 P_kD&M{p8!Ae?R{p2C2K{ diff --git a/images/logo.png b/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..52a1c4b60b355ee04ea4241ac20ae956c9c5b7c3 GIT binary patch literal 2018 zcmd5->pPTr0R6pVn0QRNRA^*m+_o0e$_QC0*Qh3`$bBtJ8Mh?Yc{P=7L)nzO2)TsO zgKVQ=YD2}&)MFU8j9Vr&reZJ#@7nz%_UY3(&pGGIIbY6A@-auO!VU!h0M^;b&J6%) zgF;}F+y;0@RR#lqsdBcnb-z*|{IK`7cdth0*xlC(WlmX!c26ccjs@lEZoR4ULwcnB z?z=k5deeW1Sj`8s-ld!)ZmWY)y1It&+q!z3jI7Egg~LIMK8XQ2g+8Ls>~0HFM38xU zJ9e-zSK1;dN|+ipw5qj6LT`QJGkKDilBba-pPB6DxqWk{sPDeU|FODNPcbbuxlbJi@7ka7 zNpjjiX88jF%TR(f9#97u%aX(Yalgdge%NpAVa)uSkaUeXl;ZeU@d*}vBOzJI?@)x) zUs=q9OQR}qdP2VuD?lCUmT=9+2MdM!2KTPoIDx5!l{?cU@(z4cotw|!=$Obh|0q5V^aV$Ygq zBZHUw`0M?QX-QFrE7<-?ay`@1Wp3QCc)hNO4-r@W{uS41Sw5Pws}i@_nOhmrRs>a8 zmXgS2R5yGyPO_?}gfF(X$4#6LgdmS%2f#E)D~f+8yrwYF+v#OP zknS$(c`wnMPN}andxXHqlxOW4X&YcXeg-=>UkPLJk;Ww{QW9hSGQnauwBHV^;n=V9 zdFEbgSIF_|WP!o@dxq(%O&-t;xK;gAu$d$S7g!60l(gF{8blM~8{uQfwj@ZM2~MH~Cr{e7gd~tQ-7S4do>vH=&QBox>rmfUldb_|89kaa zljgQ(=;?IwYNFEA(H6Yex4PvBd%Y`C&0n7WeB|uyMYRs%c1G2~wpF6}TmVFpH)*vq^f1O$7n$i4bCW z#rf`Z@jN~6Hy>HZBV|Rq595aJlW-iT2)HbG)`K;tTygs39j2{TP&{QA@6C z8;vR+mi3^m^Mch#fuRRvI-4?Vldn?wl2^X`dA{i6|P@0aq*nK%N#{gf^jO3N7ZJjWHb$5-(L zD_%FG`HOc$ikPwVCy`ApQ;<+kI0g?i`oc{Kuo0;+PAzwwNO#!c=(#Ydjgic$ oz&m3+G`m|(yJDg5{)Sp*4VE8jDd$U=8{Z9_?T^{j9rC~W4``q!F#rGn literal 0 HcmV?d00001 diff --git a/src/AzureStorage.QueueService/AzureStorage.QueueService.csproj b/src/AzureStorage.QueueService/AzureStorage.QueueService.csproj index bf91e2e..10a8386 100644 --- a/src/AzureStorage.QueueService/AzureStorage.QueueService.csproj +++ b/src/AzureStorage.QueueService/AzureStorage.QueueService.csproj @@ -14,7 +14,7 @@ Unifinity Software Corporation Azure, Azure Storage, Azure Storage Queue, Storage Queue, Queue Service, Jason Shave, Unifinity Software Corporation https://github.com/jasonshave/JasonShave.AzureStorage.QueueService - logo.jpg + logo.png README.md git https://github.com/jasonshave/JasonShave.AzureStorage.QueueService @@ -46,7 +46,7 @@ - + True \ From 1c60c3390a6870d99597eb4308c120cdcbe34b3e Mon Sep 17 00:00:00 2001 From: Jason Shave Date: Thu, 15 Oct 2026 08:14:51 -0600 Subject: [PATCH 3/3] CI: start Azurite with --skipApiVersionCheck Azure.Storage.Queues 12.28 sends API version 2026-10-06, which Azurite rejects with a 400 unless the version check is skipped. A service container cannot take command-line arguments, so Azurite is now started with docker run instead. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/dotnet.yml | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 970d11d..42f0f90 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -11,19 +11,18 @@ jobs: runs-on: ubuntu-latest - services: - # Azurite emulates Azure Storage so the integration tests run without a - # live storage account. The official image binds to 0.0.0.0, so the - # mapped ports are reachable from the runner at 127.0.0.1. - azurite: - image: mcr.microsoft.com/azure-storage/azurite - ports: - - 10000:10000 - - 10001:10001 - - 10002:10002 - 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: