to navigate Enter to open "…" all these words ANDOR to combine

Architecture Decision Record

ADR-114: Internal Commands, a Job Queue Built on the Outbox's Machinery

Status

Accepted (2026-09-09). Rides the outbox machinery of ADR-003 without extending it.

Context

The framework has had two ways to move work off the request thread and neither of them is a job queue.

The transactional outbox (ADR-003) carries events. OutboxMessage (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxMessage.cs:15) is written by the domain-event interceptor inside the same transaction as the aggregate change, and OutboxProcessor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxProcessor.cs:56) drains one table per relational source with a claim lease, exponential backoff, dead-lettering and OpenTelemetry instrumentation. It answers "this happened, tell whoever cares". It does not answer "do this later", and bending it to do so means expressing an instruction as an event, which is exactly the modelling mistake ADR-007 and ADR-008 spend their length avoiding.

The recurring scheduler (ADR-074) carries cron occurrences. IScheduledJob (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IScheduledJob.cs:36) plus ScheduledJobEntry (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/ScheduledJobEntry.cs:20) hold one row per registered job in the Default source only, and a missed occurrence is deliberately not replayed. That is right for "sweep the audit trail nightly" and wrong for "send this one order its receipt", which is a single unit of work bound to a single aggregate, must not be lost, and must not fire until the order actually commits.

So the gap is specific: a handler that wants a side effect to happen after its transaction commits has, today, either a fire-and-forget Task (lost on a crash, invisible to operations) or a hand-rolled table per feature. Templated email, outbound webhooks, tenant provisioning, saga timeouts and an operations console all need the same primitive underneath, so building it once and building it on the machinery already proven in production is the cheaper path than five bespoke tables.

Three off-the-shelf options were weighed and rejected; the reasoning is in the Rationale below.

Decision

Deferred work is an ordinary CQRS command, scheduled into a per-source InternalCommands table and executed later through the normal decorator pipeline.

Five parts carry that.

1. The contract is the one that already exists. IInternalCommand (MMCA.Common/Source/Core/MMCA.Common.Application/InternalCommands/IInternalCommand.cs:38) is a marker deriving from ICommand<Result> and nothing else. A command that opts in keeps its ordinary ICommandHandler<TCommand, Result>; there is deliberately no second handler contract, because the whole point is that a deferred execution IS the inline execution, run later. InternalCommandDispatcher (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandDispatcher.cs:49) resolves the CLOSED ICommandHandler<TCommand, Result> from the execution scope, so what comes back is already wrapped by Scrutor in the feature gate, the authorization check, logging, cache invalidation, validation, the timeout budget and the transaction (ADR-014's order, unchanged).

2. Scheduling rides the caller's unit of work. IInternalCommandScheduler (MMCA.Common/Source/Core/MMCA.Common.Application/InternalCommands/IInternalCommandScheduler.cs:16) exposes ScheduleAsync(command, runAt) and a TimeSpan delay overload, both returning Result<Guid>. The implementation (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/InternalCommandScheduler.cs:69) adds the row to the context handed back by the scope's own IDbContextFactory, which is the same instance the calling handler's repositories use. With a transaction active it enrolls the row and stops, so the caller's commit persists it and a rollback erases it; with no transaction active it saves immediately and signals the processor. That is the outbox's atomicity guarantee applied to an instruction instead of an event: a transaction that aborts schedules nothing.

3. One table per relational source, mapped unconditionally. InternalCommandMessage (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/InternalCommandMessage.cs:21) is configured in ApplicationDbContext.ConfigureInternalCommands (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:673) alongside the outbox and inbox, with three filtered indexes for the poll, the retention sweep and the dead-letter view. It takes the engine the model is being built for and runs its partial-index predicates through the same QuoteColumn (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:548) and IncludeColumns (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:574) helpers ADR-113 introduced for the outbox, so PostgreSQL gets double-quoted identifiers while every other engine keeps the bracketed literal it has always produced. PostgreSQLDbContext calls base.OnModelCreating, so the fourth engine needs no registration of its own. Cosmos ignores the entity, exactly as it ignores the outbox (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/CosmosDbContext.cs:81). The mapping is NOT gated on InternalCommands:Enabled, unlike the scheduler's job table: a row must be able to commit in the same transaction as the aggregate change, a transaction does not span databases, and a flag that changed the schema would make enabling the queue a migration rather than a deployment decision.

4. Execution restores the caller's context. The row captures the scheduling user id, roles, tenant and correlation id. Before resolving the handler the processor sets the tenant, then rebuilds a ClaimsPrincipal carrying the sub claim and one role claim per stored role (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandProcessor.cs:568) and hands it to ScopedUserOverride (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/ScopedUserOverride.cs:23), a scoped carrier read by ImpersonatingCurrentUserService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/ImpersonatingCurrentUserService.cs:20), which decorates whatever ICurrentUserService the host registered (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:243). With no override set every member reads straight through, so an HTTP request behaves exactly as before.

5. Failure policy: the outbox's, with one deliberate divergence. InternalCommandProcessor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandProcessor.cs:50) claims the due prefix of each batch with a lease token (ClaimDueAsync, :366), executes each row in a fresh DI scope, and stamps the outcome through a set-based update guarded by that token (StampAsync, :686) so a replica whose lease expired mid-execution silently drops its stale outcome. A Result.Failure and a thrown exception are the same operational fact and both consume an attempt; the backoff is RetryBackoffBaseSeconds * 2^(attempts-1) with jitter in [0.8, 1.2], capped at MaxRetryBackoffSeconds (ComputeRetryBackoffSeconds, :712). The divergence: an unresolvable command type and a missing handler registration are terminal on the FIRST attempt, where the outbox retries an unresolvable type once. An outbox row's type may live in an assembly that has simply not loaded yet; a queue row can only run on a host that registers a handler for it, and a host that cannot name the type has no such handler, so retrying would burn the attempt budget waiting for a fact that will not change.

Settings live under InternalCommands (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Administration/InternalCommandsSettings.cs:15) and mirror OutboxSettings where the semantics match: BatchSize 50 (:31), MaxAttempts 5 (:39), LeaseSeconds 300 (:68), RetryBackoffBaseSeconds 10 (:77), RetentionDays 7 (:93), CleanupIntervalHours 6 (:108). Two values deliberately differ. ProcessingDelaySeconds defaults to 0 rather than the outbox's 5 (:58), because that delay exists to bound a race with the in-process fast path that dispatches an event before the processor can, and the queue has no such fast path. MaxRetryBackoffSeconds (600, :86) is a separate ceiling rather than the lease, because a job queue wants a long lease for slow handlers and a short ceiling on how long a transient failure parks a command, where the outbox caps its backoff at LeaseSeconds and gets one number for both.

IInternalCommandAdministration (MMCA.Common/Source/Core/MMCA.Common.Application/InternalCommands/IInternalCommandAdministration.cs:17) mirrors IOutboxAdministration: count pending, list dead letters, requeue, purge completed. A requeue keeps LastError on purpose, because the reason a command failed is the first thing anyone asks after a requeue.

Rationale

Four alternatives were weighed, and each one gave up the atomicity guarantee or duplicated a vocabulary the framework already owns:

Hangfire. It is the obvious answer and it brings its own storage, its own dashboard and its own serialization. All three are the problem. Its storage is a separate set of tables with its own connection, so a job enqueued inside a handler cannot commit atomically with the aggregate change that justified it, which is precisely the guarantee this record exists to provide. Its dashboard and its IJobFilter pipeline duplicate, in a second vocabulary, the operator surface and the decorator pipeline the framework already has. And it executes a serialized method invocation, not a command, so nothing it runs passes through feature gating, authorization, validation or the transaction decorator without being taught to.

Quartz.NET. A stronger scheduler than the framework's own (calendars, misfire policies, clustering) and a weaker fit for this problem. It is built around recurring triggers, and the framework already has that shape in IScheduledJob. Its AdoJobStore is again a separate schema with no way to enlist in the caller's transaction, and adopting it would mean running two schedulers with two configuration surfaces to gain a feature set (cron calendars) nobody has asked for.

A cloud queue (Azure Storage Queues, Service Bus). The right answer once the work must cross a process boundary or survive the database being unavailable, and the wrong first step. It reintroduces the dual-write problem the outbox was adopted to solve, because a message put on a queue cannot roll back with the transaction; the standard fix is to write an outbox row that a processor forwards to the queue, which is this design plus one hop. It also adds an infrastructure dependency and a cost line to every consumer, including MMCA.Helpdesk, whose whole premise is running with nothing but a database.

Extending the outbox to carry commands. Tempting, since the machinery is exactly what is wanted. Rejected because the two rows want different columns (a scheduled instant, an attempt budget, a captured principal), different indexes, different retention and different terminal semantics for an unresolvable type, and because the outbox's poll predicate is the hottest query the framework issues. Widening it to serve a second workload would have made every outbox change a job-queue change. The duplication is bounded and deliberate: the two processors share an idiom, not an implementation, the same way ScheduledJobRunner already borrows the claim-lease idiom without borrowing the code.

Trade-offs

Consumers take one migration per relational data source. The table is in the model of every relational source (SQL Server, PostgreSQL and SQLite after ADR-113), so a consumer bumping to this version runs dotnet ef migrations add AddInternalCommands per source and applies it before deploying. That is the cost of the atomicity guarantee, and it is paid once.

Execution is at-least-once, and handlers must be idempotent. A replica that dies after its handler committed and before the row was stamped releases the row when its lease expires and the command runs again. This is the same contract the outbox already places on event handlers, so it is not a new obligation for anyone already using the framework, but it is now an obligation on ordinary commands too, which previously only ever ran once per request.

A scheduled command runs with the scheduling user's authority, not the executing host's. That is what makes an IRequiresPermission command schedulable at all, and it means the row is a durable record of an authorization decision. If the user's roles change between scheduling and execution, the stored roles win. That is deliberate (the decision was taken when the work was requested) and it is why the row is subject to the same retention discipline as the outbox: the sweep (InternalCommandCleanupService) is the retention policy, and the payload can carry personal data (ADR-003 / ADR-005).

A command scheduled inside a transaction waits up to one polling interval. The enrolled row raises no signal, because a signal before the commit only buys a poll against a transaction that has not committed. InternalCommands:PollingIntervalSeconds therefore defaults to 2 rather than the 300 a deployed outbox uses, and a host that raises it to cut idle polling accepts that much latency on transaction-scheduled work.

Two poll loops, not one. A host now runs the outbox processor and the queue processor side by side, each with its own signal instance so a burst of schedules cannot consume the outbox's single pending wake-up. Both poll spans are suppressed from telemetry export by the same Aspire processor (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Telemetry/OutboxPollFilterProcessor.cs:60), and the queue's metrics land on their own MMCA.Common.InternalCommands meter (.../Persistence/InternalCommands/Processing/InternalCommandMetrics.cs:20).

What this unlocks. Templated email, outbound webhooks with retry, tenant provisioning, saga timeouts and an operations console over deferred work now share one durable substrate with one retry policy, one dead-letter story and one operator surface, instead of a table and a poll loop per feature.

ADR-003 (the claim-lease, backoff and dead-letter machinery this record borrows as an idiom), ADR-014 (the decorator chain a deferred command runs through unchanged), ADR-074 (the cron scheduler this record is deliberately not), ADR-052 (the background execution posture the processor follows), ADR-113 (the engine-aware index helpers the table reuses), ADR-100 (why an always-mapped table is not a migration decision).