Architecture Decision Record
ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)
Status
Accepted (2026-07-24). Revised 2026-08-23 (post-commit enqueue is recorded as two patterns, not one) and 2026-08-31 (three of the four command-handler enqueue sites inherit the save-then-enqueue ordering from a shared base class instead of writing it): see the revisions at the end.
Context
Some requests trigger work that cannot run inside the request: an AI scoring pass over an event's sessions takes minutes and issues one paid API call per session, and a live-channel broadcast must not put a gRPC round trip on the hot path of a vote. The endpoint should accept the request and return, leaving the work to run elsewhere.
The obvious shortcut is to start an untracked task and return, which is what the session-scoring
endpoint did (_ = RunScoringInBackgroundAsync(eventId)). A code review found three defects that
follow from the shape rather than from that particular implementation:
- The host does not know the work exists.
IHostApplicationLifetimecannot wait for it and shutdown cannot cancel it, so an Azure Container Apps scale-in or a deploy tears it down mid-run with nothing recorded. For a multi-minute paid pass, that is spend with no result and no trace. - Nothing deduplicates it. Two clicks started two concurrent passes over the same event: double the API calls, racing each other's writes, and no way for the caller to learn a run was already in flight.
- Failure handling drifts. Each fire-and-forget site invents its own try/catch, so the failure posture (swallow, log, retry) is decided per call site instead of once.
There is a second, unrelated need with the same shape: work that must happen after the request's transaction commits and must not add latency to it. The live-channel broadcast is that case, and it already used a bounded queue plus a hosted drain (ADR-039). The two wanted the same mechanism.
This is deliberately not a distributed job system. Every case here is best-effort or re-triggerable work owned by one service; durable, cross-process scheduling is what the outbox (ADR-003) and the broker (ADR-008) are for, and this ADR does not compete with them.
Decision
In-process background work runs as a bounded queue plus a single-reader hosted drain. Nothing
starts an untracked Task from a request.
- A bounded
Channel<T>per job kind, registered as a singleton, with the concrete type and its interface both resolving to the one instance (TryAddSingleton<TQueue>()plusTryAddSingleton<IQueue>(sp => sp.GetRequiredService<TQueue>())). Registering them separately would give producers a queue nobody drains. - A
BackgroundServicedrain per queue,SingleReader, consuming withReadAllAsync(stoppingToken). Because it is a hosted service the host owns the work: shutdown cancels it and waits for it to unwind. The drain resolves scoped services throughIServiceScopeFactoryper item, since it is itself a singleton. - The full mode encodes what the work is worth.
- Ephemeral work uses
BoundedChannelFullMode.DropOldest: under backpressure the freshest broadcast is worth more than the oldest and the request path must never block. Note thatTryWritethen always returns true (it evicts to make room), so a caller cannot learn from its return value that anything was dropped; the channel'sitemDroppedcallback is the only real signal and must be wired to a counter and a log. - Expensive work uses
Waitwith a non-blockingTryWrite, so a full queue refuses the request rather than silently discarding an earlier one, and the caller is told.
- Ephemeral work uses
- Expensive work deduplicates by its natural key, with the claim taken before the write and released by the drain only when the run finishes. The dedup window therefore covers execution, not just the wait in the queue. A duplicate request gets an explicit refusal (409), not a silent coalesce.
- One failure posture per drain, stated once. The drain catches per item so one failed run cannot kill the loop, and handles shutdown cancellation separately from failure so a graceful restart is not recorded as an error.
- Post-commit work is enqueued after the write is durable, never beside it. The failure this
rules out is enqueuing while the write can still be undone, which leaves the queued work
describing state that never persisted. Two shapes satisfy it and both are in use:
- From a domain event handler, which gets post-commit delivery from the existing deferral (ADR-003) with no sequencing code in the command handler: domain-event dispatch inside a transactional command is deferred until after the commit succeeds and dropped on rollback.
- From the command handler side of a non-transactional command, once its save has returned, for
commands that do not opt into the ADR-014 transaction decorator (which wraps only commands
implementing
ITransactional, so for the restSaveChangesAsyncis the commit). Most of these handlers do not write the ordering themselves: aMutateEntityHandlerBasesubclass has no save of its own, and the shared base saves and then calls theOnMutatedAsyncpost-save hook the handler overrides to enqueue, so the order is structural. A handler that saves inline instead keeps the ordering as statement order, where the enqueue call has to stay below the save.
Two implementations exist: LiveChannelPublishQueue / LiveChannelPublishProcessor (ephemeral,
DropOldest, ADR-039) and SessionScoringQueue / SessionScoringProcessor (expensive, Wait, dedup
by event id).
Rationale
- The host lifetime is the point. A
BackgroundServiceis the only in-process shape the host can cancel and await. Everything else in the decision follows from wanting that. - The queue is where the policy lives. Capacity, full mode and dedup are properties of the work,
and putting them in the queue type means a caller cannot get them wrong: it calls
TryEnqueueand reads the outcome. - Refusal beats silent coalescing for paid work. An organizer who clicks twice should learn the run is already going, not have the second click vanish into a queue.
- Single reader gives ordering for free, which the live-channel case needs per session, and serializes expensive runs so two of them cannot contend for the same external rate limit.
Trade-offs
- In-process only. The queue does not survive a restart and does not span replicas. Accepted because every current job is either ephemeral (a lost broadcast is a missed UI refresh) or re-triggerable (an organizer can click score again). Work that must survive a crash belongs in the outbox (ADR-003), not here.
- Dedup is per replica. Two replicas can each accept a run for the same event. Today the scoring endpoint is organizer-only and low-traffic, so the exposure is small; making it cluster-wide would need a distributed lock or a claim row, which is the point at which this should become a real job system instead.
- Backpressure is felt differently by the two modes, and choosing the wrong one is a silent bug
in either direction:
DropOldeston expensive work discards paid runs, andWaiton ephemeral work turns a slow peer into refused broadcasts. The mode is a per-job decision, not a default. - A drain is a serialization point. One reader means a slow item delays the queue behind it. That is wanted for scoring and harmless for broadcasts at the observed conference-day load; a job kind that needs parallelism needs its own queue rather than a wider reader, or ordering is lost.
Related
ADR-003 (the outbox, for work that must be durable and cross-process, and the post-commit deferral this relies on), ADR-008 (the broker, for cross-service work), ADR-014 (the transactional decorator whose commit boundary post-commit work attaches to), ADR-039 (live channel push, the ephemeral instance of this pattern), ADR-025 (startup warm-up, the other hosted-service use in the framework).
Revision (2026-08-23)
The decision is unchanged: post-commit work is still enqueued only once the write is durable. What changed is the record of how. This ADR stated the domain-event handler as the mechanism; the live-channel enqueue call sites split two-to-four the other way, so the decision bullet now names both shapes.
- Two call sites enqueue from an
IDomainEventHandler.LivePollVoteChangedHandlerimplementsIDomainEventHandler<LivePollVoteChanged>(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/DomainEventHandlers/LivePollVoteChangedHandler.cs:41) and enqueues thepoll.results-changedbroadcast at:79;SessionQuestionUpvoteChangedHandlerimplementsIDomainEventHandler<SessionQuestionUpvoteChanged>(.../SessionQuestions/DomainEventHandlers/SessionQuestionUpvoteChangedHandler.cs:42) and enqueues the upvote-count broadcast at:80. Both are singletons that open their own scope (:53and:54respectively) and wrap the work inBestEffort.ExecuteAsync(:51,:52). - Four call sites enqueue from the command handler, on the line after the save. There is no
domain event in between. (Superseded by the Revision (2026-08-31): three of these four
handlers hold no save of their own today, and their anchors below have moved. The text is left in
place as the record of what was true when written.)
CloseLivePollHandlerawaitsunitOfWork.SaveChangesAsyncat.../LivePolls/UseCases/Close/CloseLivePollHandler.cs:70, logs at:72and callsEnqueueClosed(poll)at:74; the queue write itself is at:95.OpenLivePollHandlersaves at.../LivePolls/UseCases/Open/OpenLivePollHandler.cs:85and callsEnqueueOpened(poll)at:89, queue write at:110.SubmitQuestionHandlersaves at.../SessionQuestions/UseCases/Submit/SubmitQuestionHandler.cs:103and awaitsEnqueueSubmittedAsync(question)at:107; that helper is aBestEffort.ExecuteAsyncbody (:130-131) with one queue write per branch (:142approved,:157pending-count).ModerateQuestionHandlersaves at.../SessionQuestions/UseCases/Moderate/ModerateQuestionHandler.cs:80and awaitsEnqueueModeratedAsync(...)at:84, queue writes at:138and:152.
- The safety property still holds for those four, because they are not transactional commands.
TransactionalCommandDecoratorwraps only commands implementing theITransactionalmarker and passes everything else straight through (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TransactionalCommandDecorator.cs:28-29, marker at.../UseCases/Markers/ITransactional.cs:6). No Engagement command implements it, so for these handlersSaveChangesAsyncis the commit and an enqueue below it is genuinely post-commit. The difference from shape 1 is where the ordering lives: in the command handler's statement order rather than in the ADR-003 deferral, so it is a rule a future edit to those handlers has to keep rather than one the pipeline keeps for them. (The last sentence is superseded by the Revision (2026-08-31): it describes one of the four handlers, not all four.)
Revision (2026-08-31)
The decision and the safety property are unchanged: post-commit work is still enqueued only once
the write is durable. What changed is who keeps the ordering for shape 2. Three of the four
command-handler call sites named above contain no SaveChangesAsync of their own: they are
MutateEntityHandlerBase subclasses
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:320, over the
shared MutateEntityHandlerCore at :52), and the base saves once for every subclass.
- The save is one line in the shared base, not one per handler.
MutateEntityHandlerCore.MutateCoreAsyncawaitsattemptUnitOfWork.SaveChangesAsyncatMutateEntityHandlerBase.cs:303, callsLogMutatedat:305, then awaits theOnMutatedAsyncpost-save hook at:306. The enqueue is the body of that hook, so "enqueue below the save" is structural rather than remembered: a subclass has no way to express the other order.CloseLivePollHandlerdeclares the base atMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/UseCases/Close/CloseLivePollHandler.cs:24, overridesLogMutatedat:64-65and callsEnqueueClosed(poll)fromOnMutatedAsyncat:73; the queue write is at:94-95.OpenLivePollHandlerdeclares it at.../LivePolls/UseCases/Open/OpenLivePollHandler.cs:26, overridesLogMutatedat:79-80and callsEnqueueOpened(poll)at:88; the queue write is at:109-110.ModerateQuestionHandlerdeclares it at.../SessionQuestions/UseCases/Moderate/ModerateQuestionHandler.cs:28, overridesLogMutatedat:81-82, and forwards toEnqueueModeratedAsync(question, command.Action, _wasPending)as the expression body ofOnMutatedAsyncat:89; that helper wraps its work inBestEffort.ExecuteAsync(:138) with one queue write per branch (:140the moderation event,:154the pending count).
- One call site still writes the ordering by hand.
SubmitQuestionHandlerimplementsICommandHandler<SubmitQuestionCommand, Result<SessionQuestionDTO>>directly (.../SessionQuestions/UseCases/Submit/SubmitQuestionHandler.cs:31), so it owns its own sequence: it awaitsunitOfWork.SaveChangesAsyncat:103, logs at:105, and awaitsEnqueueSubmittedAsync(question)at:107, whoseBestEffort.ExecuteAsyncbody (:130-131) writes the queue at:142(approved) and:157(pending count). This is the one handler where an edit that lifted the enqueue above the save would break the property with nothing to catch it. - Point 3 above is narrowed, not withdrawn. Its premise still holds:
TransactionalCommandDecoratorwraps only commands implementingITransactionaland passes everything else straight through (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TransactionalCommandDecorator.cs:28-29, marker at.../UseCases/Markers/ITransactional.cs:6), and no Engagement command implements it, soSaveChangesAsyncis the commit for all four sites. What no longer holds is its closing sentence: for the three base-class handlers a pipeline does keep the ordering, and the rule a future edit has to keep by hand lives inSubmitQuestionHandleralone.