Skip to content

3.1: breaking API cleanup, batch handlers, pre-created Blob containers (#51) - #53

Merged
jasonshave merged 4 commits into
masterfrom
v31-remainder
Oct 16, 2026
Merged

jasonshave merged 4 commits into
masterfrom
v31-remainder

Conversation

@jasonshave

Copy link
Copy Markdown
Owner

Finishes the "Remaining for 3.1" list in #51. Merging releases nothing: 3.1.0 still needs a tag, which the owner does after validating.

What changes for users

Breaking changes from 3.0 (each a small edit)

3.0 has been out only briefly, so these were made now rather than held for 4.0. No aliases or [Obsolete] forwarders.

  • SendAsync returns SendResult, not the SDK's SendReceipt. Rename InsertionTime → InsertedOn, ExpirationTime → ExpiresOn, TimeNextVisible → NextVisibleOn. A fake sender returns new SendResult(...), so tests no longer need QueuesModelFactory.
  • GetPropertiesAsync returns QueueStatus, not the SDK's QueueProperties. ApproximateMessagesCount and Metadata keep their names. A fake returns new QueueStatus(count, metadata).
  • RenewLockAsync is now RenewVisibilityAsync. Rename the call.
  • QueueClientSettingsBuilder is now QueueServiceBuilder. Only code that names the type (e.g. a helper taking Action<QueueClientSettingsBuilder>) changes.
  • The default serializer writes camelCase JSON ({"orderId":42}). It reads either case, so messages from 3.0 senders still arrive. Other readers of the queue that expect PascalCase (an Azure Function, a script) will see empty fields. To keep PascalCase, pass UseJsonSerializer(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }).

New: batch handlers

For work that's cheaper in bulk (database inserts, bulk APIs), a handler can now take up to 32 messages per call:

public class OrderBatchHandler(OrdersDbContext db) : IQueueBatchHandler<Order>
{
    public async ValueTask HandleAsync(IReadOnlyList<QueueMessage<Order>> messages, CancellationToken ct) { ... }
}

queues.AddClient("orders", ...).AddBatchProcessor<Order, OrderBatchHandler>(o => { o.MaxBatchSize = 20; o.MaxConcurrentBatches = 2; });

Each message is still handled as its own message:

  • Its lease is renewed from the moment it's received.
  • If its body can't be read, or it has been delivered more than MaxDequeueCount times, it goes to the poison queue on its own and isn't passed to the handler. If nothing is left, the handler isn't called.
  • When the handler returns, every message it didn't settle itself is completed.
  • When the handler throws, the error policy (DeadLetterOn, then OnHandlerError) runs once for each message the handler hadn't settled. A message the policy doesn't handle is abandoned, with the retry delay for its own dequeue count.
  • The handler can settle individual messages itself (e.g. dead-letter one bad item and return).

A batch is whatever one receive returns, so the processor never waits to fill one. Each batch gets one DI scope, one trace span (linked to every sender's trace) and one log scope. Metrics are recorded per message, and status counts messages, so dashboards read the same as for single-message processors. Shutdown behaves exactly as for single-message processors. Middleware does not run for batch processors, because it is built around one message. The README says so.

The options both processor types share moved to a new base class, QueueProcessorOptionsBase. Code that sets options doesn't change. The batch side has MaxBatchSize (1–32, default 32) and MaxConcurrentBatches (default 1) instead of MaxConcurrentMessages.

New: pre-created Blob containers

UseBlobStorageForLargeMessages(..., createContainerIfNotExists: false) lets an identity that can't create containers use the short registration with a container created ahead of time. Existing calls are unchanged.

Fixed (never released)

The 3.1 receive loop (#52) stalled when a RateLimiter granted every permit it asked for. It called SemaphoreSlim.Release(0), which throws, so every receive counted as failed and backed off. This is fixed, and a regression test covers it.

Under the hood

The single-message and batch processors share one base class: start/stop, the receive loop, pausing, status, and the per-message steps (lease renewal, reading the body, poison checks, error policy, settlement). Only "deliver one message" vs "deliver a batch" differs, so there is no second copy of the processor.

Tests

Locally against Azurite: 204 passed, 0 failed (core 166, Blobs 21, integration 10, health checks 7). This PR adds 4 batch tests (batching up to MaxBatchSize; partial settlement then completion; a throwing handler gets the per-message policy and abandon; unreadable messages are left out and dead-lettered) and 1 rate-limiter regression test. All run on in-memory queues with a fake clock, so none wait on real time. dotnet build -warnaserror is clean on all three src projects (docs plus trim/AOT analyzers).

🤖 Generated with Claude Code

Jason Shave and others added 4 commits October 16, 2026 15:23
…sages

All three UseBlobStorageForLargeMessages overloads take an optional trailing
`bool createContainerIfNotExists = true`, so an identity that can't create
containers can use the short registration against a container created ahead
of time instead of falling back to UseLargeMessageStore(new BlobLargeMessageStore(...)).
Existing calls are unchanged. Docs and both READMEs updated; one Azurite test
checks a missing container is not created and the send fails with 404.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erviceBuilder, camelCase JSON by default

The four changes #51 held back for 4.0 go into 3.1 as clean breaks, since 3.0
shipped with negligible uptake:

- IQueueSender.SendAsync returns SendResult and IStorageQueue.GetPropertiesAsync
  returns QueueStatus instead of the Azure SDK's SendReceipt and QueueProperties,
  so fakes no longer need QueuesModelFactory. Both transports convert at the edge.
- QueueMessage<T>/IQueueMessage.RenewLockAsync is renamed RenewVisibilityAsync.
- QueueClientSettingsBuilder is renamed QueueServiceBuilder.
- The default serializer uses JsonSerializerDefaults.Web: camelCase out,
  case-insensitive in. Options passed by the application are used as given.

README lists them as breaking changes from 3.0 with the migration for each;
v3-design.md records why they moved into 3.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- IQueueBatchHandler<TMessage> and AddBatchProcessor<TMessage, THandler>() (plus an
  IConfiguration overload): up to MaxBatchSize (1-32, default 32) messages per handler
  call, MaxConcurrentBatches (default 1) batches at once, one scope per batch.
- Every message keeps its own lease renewal (armed before any body is read), poison
  checks, error policy and settlement. Unreadable or over-delivered messages are
  dead-lettered individually and left out; return completes what the handler left
  unsettled; throw applies DeadLetterOn/OnHandlerError per unsettled message, else
  abandons each with RetryDelay for its own dequeue count. No middleware for batches.
- The processor is split into QueueProcessorBase<TMessage> (lifecycle, receive loop,
  status, per-message steps) and two deliveries: one message, or one batch.
- Options shared by both moved to QueueProcessorOptionsBase.
- Fix: a processor with a RateLimiter that granted every requested permit called
  SemaphoreSlim.Release(0), which throws, so every receive failed.
- One span per batch linked to every sender; per-message metrics; status counts messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jasonshave
jasonshave enabled auto-merge (squash) October 16, 2026 21:42
@jasonshave
jasonshave merged commit 428f394 into master Oct 16, 2026
1 check passed
@jasonshave
jasonshave deleted the v31-remainder branch October 16, 2026 21:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant