Repository navigation
Conversation
Abandon immediate direct-subscription retries with their handled count and route delayed retries through dedicated forwarding queues. Reject unsafe delayed retries and purge modes before publishing or deleting infrastructure. Refs BrighterCommand#4546 BREAKING CHANGE: Delayed retries on plain subscriptions require forwarding or queue configuration. Immediate abandon now increments the broker delivery count. Compatibility policy is proposed for discussion in the draft PR.
|
@gabisonia This is marked as draft at the moment? |
Yes, I created it as a draft PR to discuss the proposed approach before proceeding with the implementation. Could you please check the discussion in the linked issue first? #4546 (comment) |
Expose optional sync and async requeue scheduler capabilities and use native scheduling on the consuming broker. Assign stable per-attempt delivery IDs to survive duplicate detection, and acknowledge only after scheduling succeeds. Preserve direct-subscription startup and abandon with a warning when delays cannot be honored. Cover failure recovery, broker ownership, forwarding isolation, sessions, and retry identity through regenerated conformance assertions. Validation: 26 focused cases pass on .NET 9 and 10; full ASB suite has 553 passed, 4 skipped, and the same 10 baseline emulator TTL failures. Azure scheduler tests: 13 passed. Generator tests: 312 passed. Release packages build for all supported targets.
Describe configuration, delivery identity, migration, rollback, recovery, and live Azure acceptance checks. Record local regression and package results. ADR readability (before -> after): blocks: 43 -> 55 over150: 0 -> 0 over200: 0 -> 0 bold_prose: 1 -> 1 bold_leads: 0 -> 0 diagrams: 0 -> 0 h4: 2 -> 2 list_share: 13/17 -> 14/27 prose_bold_leads: 0 -> 0 run: 2 -> 5 span: 10 -> 26 units40: 0 -> 0 units50: 0 -> 0 units60: 0 -> 0 worst: 17 -> 17 Length targets: zero oversized blocks and 40+ word units; both remain zero. The longer heading span and prose run reflect the added scheduler contract and broker ownership rationale. No diagrams require rendering.
|
It's worth us adding an issue to pick this up in ValidatePipelines as well |
|
@claude Let's review this addition of a scheduler to allow us to requeue a message to the queue from which we were reading instead of to the topic (resulting in a broacast to all subscribers over a retry to a queue). We are looking for issues with the implementation of the requeue scheduler and the logic that tries this native requeue scheduler before falling back to any configured scheduler |
|
Claude finished @iancooper's task in 3m 18s —— View job Review: native requeue scheduler and how it falls back
SummaryThe main design works. With Most of my concerns are about the fallback logic. As wired today, the configured scheduler is effectively never used, and the precedence rule sits in a hidden cast. 1. The fallback to the configured
|
| Path | Producer | Is the producer a requeue scheduler? | Is Scheduler consulted? |
|---|---|---|---|
ForwardTo |
AzureServiceBusQueueMessageProducer |
yes | never |
UseServiceBusQueue |
AzureServiceBusQueueMessageProducer |
yes | never |
| direct subscription | AzureServiceBusTopicMessageProducer |
n/a | returns before the cast |
As a result:
IAmAChannelFactoryWithScheduleronAzureServiceBusChannelFactoryand theSchedulerproperties on the consumer factory and consumer add public API but change no behaviour on any built-in path. TheschedulerModecases 1 and 2 inWhen_scheduling_a_retry_should_target_its_queue_and_survive_duplicate_detectiononly show that the scheduler is ignored.- No test exercises the fallback branch itself. It only runs if someone builds a consumer by hand with a custom producer.
- Ordinary schedulers (Quartz, Hangfire, InMemory) are skipped silently. Only an
IAmAMessageRequeueSchedulerAsynccounts, and nothing is logged. Someone who registers a global scheduler expecting it to own delayed retries won't be told.
Two options: (a) remove the Scheduler plumbing from this PR and add it when a real caller needs it, or (b) keep it, add a test that drives the fallback, and log at debug/warn when a configured scheduler is ignored. The CLAUDE.md rule on change scope leans towards (a).
2. The fallback contradicts its own comment about the namespace
AzureServiceBusConsumer.cs:391: "Native retries must stay on the consuming broker, regardless of a global scheduler's namespace." The next line then falls back to the global scheduler. The MessageScheduler.Azure AzureServiceBusRequeueScheduler sends with its own IServiceBusClientProvider (AzureServiceBusSchedulerFactory.cs:62). If that provider points at a different namespace, destination (a bare queue name) resolves against the wrong broker. The result is either MessagingEntityNotFound or, worse, a delivery to a queue with the same name in another namespace. ADR 0082 hands this to "custom components," but nothing checks it. If the fallback stays, either compare the scheduler's FullyQualifiedNamespace with the consumer's, or state in the XML docs that the scheduler must share the consumer's namespace.
3. The precedence rule depends on the producer secretly being a scheduler
AzureServiceBusQueueMessageProducer : …, IAmAMessageRequeueSchedulerAsync, IAmAMessageRequeueSchedulerSync. Since these interfaces extend IAmAMessageScheduler, the queue producer is now also an IAmAMessageScheduler. That mixes two roles in one object (see design_principles.md). The consumer then discovers the capability with an as cast on a field typed IAmAMessageProducer. The factory already has _clientProvider, so this would be clearer:
new AzureServiceBusQueueConsumer(sub, producer, admin, receiverProvider,
requeueScheduler: new AzureServiceBusRequeueScheduler(_clientProvider))The consumer would hold the requeue scheduler explicitly, the producer would go back to only producing, and the "local broker first" rule would show up in the constructor instead of a cast. This would also make finding 1 go away.
4. IAmAMessageRequeueSchedulerSync is new public core API with no caller
The consumer's sync Requeue runs RequeueAsync through BrighterAsyncContext, so only the async interface is ever checked (AzureServiceBusConsumer.cs:354, 392). The sync interface and its three implementations are unused. Either remove them, or have the sync path prefer the sync capability so the Reactor gains something from it.
5. Retry sends skip the producer's tracing and connection retry
The old path went through AzureServiceBusMessageProducer.SendWithDelayAsync, which had:
BrighterTracer.WriteProducerEvent(...): retries no longer produce a producer span or event. That hurts observability exactly when things are failing.- The Polly
TopicConnectionRetryCountpolicy around_serviceBusSenderProvider.Get. The new scheduler calls_senderProvider.Getonce with no retry. - Wrapping errors in
ChannelFailureException. The new path lets rawServiceBusExceptionthrough. The pump catchesException, so nothing breaks, but the error type differs from every other producer failure.
The first is the one to fix. The other two are consistency points.
6. A new sender (AMQP link) is created and closed for every retry
AzureServiceBusRequeueScheduler.cs:96-105. The existing producer works the same way, so this isn't a regression. But retries spike during incidents, and opening and tearing down a link per failed message adds latency and load just when the broker is already under strain. A small cache of senders per destination, disposed with the scheduler, would help. This can be a follow-up.
7. The CloudEvents id changes on every retry
AzureServiceBusRequeueScheduler.cs:94 overwrites ce-id with the new broker ID. Brighter reads Message.Id from the broker MessageId (BrokeredMessageWrapper.Id), so the broker-side fix doesn't need this change. The CloudEvents spec says source + id identifies the event and a resent event may keep its id. Any downstream consumer that de-duplicates on ce-id will now treat each retry as a new event. I'd keep ce-id stable and change only the broker MessageId, unless this was a deliberate choice. The ADR says it "matches" but doesn't say why.
Smaller points
- Destination check (
:74-76): on Linux,Uri.TryCreate("/foo", UriKind.Absolute, …)returns true because it's parsed as a file URI. That's harmless, since ASB names can't start with/, but the check doesn't do what it appears to. A subscription path such astopic/Subscriptions/apasses the check even though the interface docs forbid it. The broker will reject it, just later. - TTL isn't carried over:
ConvertToServiceBusMessage(message)is called without the original's TTL, so the retry gets the queue's default TTL. That's probably fine, but worth a sentence in the ADR. - Sessions:
SessionIdis preserved, which is good. A delayed retry still goes to the back of its session, so later messages in that session are processed first. The behaviour hasn't changed, but the guide should mention it now that delayed session retries are a supported path. - Repeated scheduling failure: after a failure the pump calls
NackAsync, which uses plainAbandonAsyncand doesn't persist the handled count. Brighter's count therefore stops moving and only the broker'sMaxDeliveryCountlimits the loop. That's acceptable, but it's another reason the guide's "setMaxDeliveryCountabove Brighter's retry budget" advice matters. SHA256.Create()per call plusBitConverter.ToString(...).Replace("-", "")is fine for netstandard2.0. The ID is 79 characters, within ASB's 128-character limit.
What looks right
- The order is schedule then acknowledge. Cancellation goes to the schedule only.
AcknowledgeAsynceffectively ignores the token, so an ack can't be cancelled after a successful schedule. - The input
Messageisn't modified, so the original lock token stays valid for the ack. x-original-message-idis set once and carried through, and the Proactor and Reactor already use it in their "dropping message" logs.- Body bytes are copied from
Body.Memory, so binary payloads survive. - Lock token, sequence number and session ID are kept out of
ApplicationPropertiesbyASBConstants.ReservedHeaders.
Recommendation
Decide what the Scheduler fallback is for (findings 1–3) before merging. My preference is to inject the requeue scheduler into the consumer explicitly and drop the unused Scheduler and sync plumbing for now. Ian's suggestion to also check ForwardTo / UseServiceBusQueue in ValidatePipelines would catch the namespace and configuration mistakes in finding 2 at startup.
|
@gabisonia It may be worth thinking about this "native requeue" model a little deeper for other brokers. NServiceBus use a combination of TTL and a dead letter exchange to create a scheduled resend to a queue for example, which we could treat at a native scheduled requeue. |
|
@gabisonia This has grown in the doing, but its moving in a really positive direction. Some stuff to lok at from the Claude review:
See 3, but we should surface this option via the Publication i.e. you should choose if you want to use the native broker scheduler or a global one and that would push through to 3. It would also be easier to imply that there are steps, like ForwardTo, that come with that choice
If you solve 1, then you can allow the fallback to global to not follow this
This looks like a cleaner design, as it allows us to also more easily choose
We should prefer to be "sync all the way down" or "async all the way down" so prefer to use sync options in Reactor
Agreed, we should have consistency particularly around OTel
This is worth fixing, but you can push to a new issue
Agreed, worth remaining consistent Worth reviewing the nits too |
|
@claude Please re-review this PR after the latest updates. Revisit your previous findings and identify which are resolved or still outstanding. Pay particular attention to explicit native/global retry scheduler selection, sync/async compatibility, queue-path validation, retry failure and settlement behavior, duplicate detection, and trace propagation without modifying the original message. Check the tests for remaining gaps and report actionable findings with file and line references. Review only; do not modify code. |
|
Claude finished @gabisonia's task in 3m 47s —— View job Re-review: native and global requeue scheduling
SummaryThis is a real improvement. The producer no longer doubles as a scheduler. The requeue scheduler is now injected into the consumer, and the factory chooses it explicitly from Six of the seven earlier findings are resolved. The seventh, a new sender per retry, was deferred to a follow-up issue as agreed. What's left is mostly small and follow-up work. Two items are worth fixing before merge (A and B below). Earlier findings: status
New / remaining findingsA. Dead code: B. Retry telemetry ignores the configured instrumentation options (
C. The retry event records the failed delivery's ID, not the new one ( D. The sender-creation retry wraps an operation that doesn't touch the network (
E. Hand-built consumers find an incompatible scheduler only at runtime (
F.
G. Direct subscriptions log the warning on every retry ( H. Nit: unclear error when Settlement, duplicate detection and the original message: verified
Test gaps
RecommendationThe scheduler selection, sync/async wiring, settlement order and identity handling now look correct. Before merge, I'd fix A (dead code) and B (telemetry options), and add test gap 1. C–H can be done here or tracked as follow-ups alongside the issues for the sender cache (#6) and the |
|
@gabisonia This has come a long way. Thanks for sticking with it. A-C definitely need fixing. Up to you if you tackle the others. Test 1 would be useful. |
Cover instrumentation, scheduler validation, sender construction failure, warning frequency, abandon failures, and real broker redelivery after settlement failure. Characterisation mutations: random retry IDs, bypassed queue validation, dropped trace metadata, and skipped abandon produced 32 expected failures. An explicit trace-header assertion was also checked with the dropped-context mutation. All mutations were restored.
Honor native and global retry instrumentation options and distinguish retry delivery IDs from stable CloudEvents IDs. Preserve trace propagation independently of diagnostic serialization. Reject incompatible manual queue schedulers, clarify missing global configuration, warn once for unsupported delays, and rely on SDK network retries instead of retrying sender construction. Remove the unused producer destination override.
|
@claude Please re-review the latest commits and assess whether this PR is ready to merge and suitable for production use. Review only; do not modify code. The latest changes address findings A–H from your previous review: I also checked Ian's latest comment: #4559 (comment). His required fixes A–C and suggested Test 1 are included; D–H are addressed as well.
Regression coverage includes actual broker redelivery after scheduling succeeds but completion fails, duplicate detection, invalid queue paths, instrumentation and trace propagation without an active Activity, sender construction failures, direct abandon failures, and receivers without property-preserving abandon support. Characterisation tests were checked with deliberate production-code mutations, all subsequently restored. Please verify these fixes against the final code, revisit any remaining test gaps, and identify any production blockers involving message loss, unintended broadcasts, duplicate detection, settlement ordering, cancellation, telemetry privacy, or sync/async compatibility. Report actionable findings with severity and file/line references, and explicitly state whether you recommend merging. Sender caching and broader ValidatePipelines diagnostics remain documented follow-up work, as previously agreed. Validation before the remote master merge: 149 retry tests passed on net10.0; 48 focused cases and 13 Azure scheduler tests passed on net9.0. The full ASB net10.0 suite reported 628 passed, 10 failed, and 4 skipped. The ten failure names exactly match the pre-change baseline and concern the emulator's one-hour TTL limit. No live Azure validation was performed. The remote master merge has now been incorporated locally without conflicts. The all-target Release build passed again with zero warnings and errors. The final retry-focused net10.0 run finished with 155 passed and one failed: ASBConsumerTests.When_requeueing_a_message_via_the_consumer, the same DefaultMessageTimeToLive emulator-limit failure present in the baseline. There were no new failures. Both sync/async broker-redelivery cases and all ten instrumentation cases passed in this final run. |
|
Claude finished @gabisonia's task in 2m 32s —— View job Final re-review: native and global requeue schedulers
VerdictI recommend merging once the low-severity documentation items below are fixed. I found no production blockers in the code. A–C are fixed, D–H are handled, and suggested test 1 now runs against a real broker redelivery for both pump types. "Suitable for production" has the same limits as before. Nothing has been checked against live Azure: permissions, recovery, load, and migration and rollback are untested. The guide's acceptance checks should be run before rolling this out to a production namespace. That is a release gate, not a merge blocker. Previous findings A–H: status
Production-risk checklist
Remaining findingsL1 (Low, docs): queues that Brighter creates don't have duplicate detection, so the ack-failure dedup protection depends on how the operator provisions the queue. L2 (Low, docs): a forwarding queue with a finite L3 (Low, docs): the release notes are out of date and leave out the main behaviour change.
N1 (Nit): repeated schedules within one consumer. Test coverage: earlier gaps
Remaining gap (optional): no test covers a Follow-ups already agreed (please make sure issues exist before merge)
|
|
Thanks, @gabisonia I am going to begin merging this one now |
Description
Deferring a delivery from one Azure Service Bus topic subscription republishes it to every matching subscriber. Reusing the original MessageId on a retry queue with duplicate detection can also discard the retry while the original is acknowledged.
Direct subscriptions now abandon with the updated handled count. If a delay was requested, they warn and retry immediately, preserving startup compatibility without broadcasting. Opt-in
ForwardToprovisions a dedicated queue and retains delayed retries; existing forwarding can also be managed externally withOnMissingChannel.Assume.The implementation adds optional sync/async requeue-scheduler capabilities and an Azure Service Bus native implementation. Built-in queue producers schedule retries on their own broker by default, ahead of any global scheduler. Each attempt has a distinct, deterministic delivery ID and retains the first ID in
x-original-message-id. The original is acknowledged only after scheduling succeeds. Ordinary Azure scheduled commands and requests keep their existing envelope behavior.Compatibility: direct subscriptions cannot retain a delay until forwarding is configured. Abandon increments the broker delivery count; configure
MaxDeliveryCountabove Brighter's retry budget. Queue retry IDs change, and send/settlement remain nontransactional, so handlers must remain idempotent. Existing subscriptions are never automatically migrated. Custom direct-subscription receivers require the property-preserving abandon capability.Related Issues
Fixes #4546. Fixes #4490. Related to the queue-targeted scheduler proposal in the issue discussion.
Design:
docs/adr/0082-isolate-azure-service-bus-subscription-retries.md.Deployment, rollback, and recovery:
docs/guides/azure-service-bus-retries.md.Type of Change
Checklist
Validation
git diff --checkis clean.Live Azure validation remains outstanding: least-privilege permissions, process/network recovery, representative load, and migration/rollback. The deployment guide defines these release acceptance checks. This PR is ready for review, but local emulator results do not establish production readiness. Public documentation publication and maintainer API/design approval remain pending.
Please merge with a merge commit rather than squash or rebase, as requested by the contribution guide.