Skip to content

3.1: in-memory test queues, CloudEvent processor, OTel trace propagation, shared credential, safe options copy - #50

Merged
jasonshave merged 7 commits into
masterfrom
v3.1-issues
Oct 16, 2026
Merged

jasonshave merged 7 commits into
masterfrom
v3.1-issues

Conversation

@jasonshave

Copy link
Copy Markdown
Owner

Folds every open issue (#45–#49) into one PR for 3.1. Everything is additive: no breaking change to 3.0's public API, defaults, telemetry names or wire format.

What's new for users

  • In-memory queues for tests (3.1: in-memory queue for testing handlers without Azurite #45). services.AddInMemoryQueues() swaps every registered client, including processors, for an in-memory queue. A handler can then be tested end to end without Azurite: send, process, then complete, retry or dead-letter, all on a fake clock. No connection string is needed in tests, but the queue-name and poison-queue rules still apply. The README lists where the fake differs from the real service.
  • CloudEvent processor (CloudEvent support: hand the handler the typed data from an Event Grid envelope #48). AddCloudEventProcessor<TData, THandler>() with ICloudEventHandler<TData> gives the handler the event's typed data plus its metadata. Unreadable data takes the processor's normal poison-queue path. The README covers Event Grid's Base64 setting. The Event Grid schema (EventGridEvent) is out of scope.
  • Telemetry (3.1: OpenTelemetry messaging semantic conventions, DI-owned meters, trace context through the queue #46).
    • Spans gain the standard OpenTelemetry messaging attributes (messaging.system = azure.storage.queue, messaging.destination.name, messaging.operation.*, messaging.message.id). Every 3.0 span name, tag and metric is unchanged; metrics keep exactly their 3.0 tags so stored history isn't split.
    • The meter is created through the host's IMeterFactory when one exists, so metrics can be tested with MetricCollector.
    • New opt-in QueueClientSettings.PropagateTraceContext carries W3C trace context inside a small JSON wrapper, so a single trace runs from sender to handler. Every 3.1 receiver unwraps it whether or not the setting is on; only 3.0 or non-library consumers need care. A message moved back from the poison queue keeps its trace context. The README documents the wire format and the size overhead.
    • Failed sends, receives and handlers now mark their span as failed.
  • One login for every client (Decide: build on Microsoft.Extensions.Azure to get credentials from configuration #47). queues.UseTokenCredential(new DefaultAzureCredential()) applies one credential to every client that has an endpoint and no login of its own, so appsettings only needs endpoints. Decided against building on Microsoft.Extensions.Azure, which would add Azure.Identity to every consumer.
  • No more editing the caller's Azure settings (Library briefly attaches its decoding handler to caller-owned QueueClientOptions #49). On Base64 queues the library builds its SDK clients from a private copy of QueueClientOptions, instead of briefly adding its handler to the caller's object. Two Azure.Core internals are read by reflection: the policy list and the caller's decoding handlers. If either ever changes, or the options are a subclass, the library falls back to the 3.0 behaviour and logs warning 45 once, so it never fails startup. A guard test flags any Azure SDK change to these types.

Review

  • Two independent reviews of the combined change, one of them /code-review high, plus focused reviews of Library briefly attaches its decoding handler to caller-owned QueueClientOptions #49 and of the fix commit. Fixed in this PR:
    • A malformed trace wrapper could make receives throw and keep a message from ever reaching the poison queue.
    • CloudEvent data failures weren't counted as failures.
    • The size metric had changed meaning.
    • The in-memory fake differed from production on trace wrapping and on queue-name validation.
    • Duplicated helpers were merged.
    • Subclassed options could fail startup.

Verification

  • dotnet build: 0 warnings.
  • 134 unit tests pass in about 0.3 s, with no real-time waits.
  • 10 integration tests pass against Azurite, including the wrapper round trip on both a plain and a Base64 queue.

Closes #45, closes #46, closes #47, closes #48, closes #49.

🤖 Generated with Claude Code

Jason Shave and others added 7 commits October 16, 2026 13:28
services.AddInMemoryQueues() makes every registered client (default and
named, injected, from the factory, and each processor's queue) an
in-memory queue that follows the service's rules: visibility timeouts and
dequeue counts on receive, new pop receipts on receive and update, 404
MessageNotFound for an outdated receipt, SendOptions visibility delay and
time to live, dead-lettering to the client's poison queue name, and the
1-32 receive limit. Time comes from the registered TimeProvider, so a
fake clock drives polling, retries and expiry.

No connection details are needed or checked, including by ValidateOnStart;
a queue name is still required and poison queue names are still validated.
Clients naming the same queue share it, which is how a test reads the
poison queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Event Grid delivering to a storage queue with the CloudEvents schema is the
package's original use case, but a handler had to unwrap the envelope itself.
ICloudEventHandler<TData> receives the event's data read as TData with the
client's serializer, plus the CloudEvent for its metadata.

It runs on the existing processor through an internal adapter, so settlement,
retries, the poison queue, lease renewal and shutdown are unchanged. Data that
cannot be read as TData is treated as an unreadable body: dead-lettered with
the event id and type in the reason, or passed to the handler as null with
message.Error set when DeadLetterOnDeserializationFailure is off. TData =
BinaryData passes the raw bytes through (data_base64 / non-JSON data).

QueueProcessor gains two optional constructor parameters (a handler factory
and a display name) so the adapter can be built per client and logs name the
application's handler rather than the adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pt-in trace propagation (#46)

- Spans carry the OpenTelemetry messaging attributes (messaging.system
  "azure.storage.queue", destination, operation type/name; message id, batch
  count, server address/port and error.type with an error status on failure)
  alongside the existing tags. Span names unchanged; metrics keep exactly their
  3.0 names and tags, so stored series stay continuous.
- The meter is created through IMeterFactory when the container has one, so
  MetricCollector can observe a host's metrics; otherwise a library-owned meter.
  Hosted processors, including CloudEvent processors, share the same instance.
- QueueClientSettings.PropagateTraceContext (off by default) makes SendAsync wrap
  the body in a JSON envelope carrying W3C traceparent/tracestate. Every client
  unwraps the strict envelope on receive and peek regardless of the setting, and
  still reads plain messages. The processing span links to the sender's context
  and uses it as parent when there is no ambient span. Dead-lettering copies the
  envelope so poison messages replay with their context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
QueueClientSettingsBuilder.UseTokenCredential(TokenCredential) gives every
client with an EndpointUri and no TokenCredential of its own a shared
credential, so appsettings only needs the endpoint. Applied by a single
IPostConfigureOptions that reads the credential from the registry, so it
covers clients registered before and after the call, default, named and
configuration-bound, runs before ValidateOnStart and first-use validation,
and a second call replaces the credential. A client's own credential wins;
connection-string clients are untouched. Poison queues inherit it through
ForSiblingQueue. No new dependencies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With MessageEncoding = Base64 the library added its MessageDecodingFailed
handler to the caller's own QueueClientOptions while building its SDK client.
A QueueClient the app built from the same options at that moment captured the
handler, which throws outside a library receive.

The library now copies the options and attaches its handler only to the copy,
for the source client and the poison-queue client. The copy carries every
public setting plus what has no public getter: policies added with AddPolicy
(at their positions), the caller's own decoding handlers, and Transport only
when the caller set one. Those three are read by reflection in one place. If an
Azure SDK update removes them, the library logs once (event 45) and falls back
to the 3.0 temporary add/remove instead of failing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ata, in-memory parity

- TraceContextEnvelope.TryUnwrap never throws: bad body64 (TryGetBytesFromBase64) and
  invalid UTF-8 strings make a lookalike a plain message instead of failing the whole receive.
- CloudEvent data is read by the processor straight after deserialization (an internal
  after-deserialize step), so unreadable data takes the processor's own path: its options
  snapshot, outcome dead_lettered and the failure metric. The adapter only hands data over.
- queue_message_size_bytes records the serializer's output, before any envelope, as in 3.0.
- Wrap skips the whole-payload JSON check for the library's own serializer.
- In-memory queues wrap on send with PropagateTraceContext and unwrap on receive/peek, like
  StorageQueue; they apply the same endpoint/queue-name rules as production.
- StorageQueue and InMemoryStorageQueue share one helper (StorageQueueCommon) for limits,
  envelope encode/decode and deserialization; InMemoryQueue reuses the shared constants.
- Tests share one Signal helper; README and XML docs state the envelope's size overhead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…py failure

- QueueClientOptionsCopy refuses options whose exact type isn't QueueClientOptions
  or SpecializedQueueClientOptions: a subclass can hold settings the copy can't see
  (a raised application id limit broke client construction) and names its own
  assembly in User-Agent and telemetry.
- SdkQueueClientFactory falls back to the 3.0 temporary handler when the copy or
  building the client from it fails. A genuine configuration error throws again
  from the fallback, so it still surfaces. Warning 45 is logged once, after the
  fallback succeeds.
- Transport is always copied; the IsCustomTransportSet reflection is gone (the
  storage SDK never consults it).
- An internal constructor takes the copy function so a test can force the
  fallback; the drift guard now checks every stored field of the option types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jasonshave
jasonshave marked this pull request as ready for review October 16, 2026 19:56
@jasonshave
jasonshave enabled auto-merge (squash) October 16, 2026 19:56
@jasonshave
jasonshave merged commit 7264a92 into master Oct 16, 2026
1 check passed
@jasonshave
jasonshave deleted the v3.1-issues branch October 16, 2026 20:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment