Skip to main content

Module dispatch

Module dispatch 

Source
Expand description

The outbox dispatcher: the second half of the external-effect saga (spec §16.4, §16.5, ADR-007).

execute enqueues an outbox row inside the turn’s one atomic write, and stops there — deliberately, because the row must be durable before anything leaves the building. Somebody then has to pick the row up and call the remote system. That is this module: a reference dispatcher an adopter can use as it stands, or read and replace.

§What it is not

It is not a background thread. Nothing here spawns anything. The library never starts work an application did not ask for: OutboxDispatcher has one method that does a unit of work, OutboxDispatcher::run_once, and the application drives it from its own task, its own scheduler or its own cron job. A hidden worker would dispatch external effects out of a process that was only supposed to answer a turn, and would keep doing it while the operator was shutting the process down.

ⓘ
// The application's own task. Cancel it, pause it, scale it — it is yours.
let mut ticker = tokio::time::interval(Duration::from_secs(1));
loop {
    tokio::select! {
        _ = shutdown.cancelled() => break,
        _ = ticker.tick() => {
            match dispatcher.run_once(Utc::now()).await {
                Ok(report) => tracing::debug!(dispatched = report.claimed),
                Err(error) => tracing::warn!(%error, "the outbox could not be read"),
            }
        }
    }
}

§The four ways one row ends

OutboxSender::send classifies its own outcome, and the classification is the whole safety contract of the module:

DispatchedWhat the row becomesWhy
CompletedCompletedthe remote confirmed
RetryablePending with a backoff, or Failed once the attempts are spentthe request demonstrably did not arrive
PermanentFailedthe remote refused, and will refuse again
UnknownOutcomeUnknownthe effect may exist, and repeating it is the duplicate the library exists to prevent (I15)

A send that does not answer within DispatchConfig::send_timeout is Unknown, never a retry. That is the same rule the executor applies to a domain timeout, for the same reason: the request left, so nobody can say it did not land.

§Exclusivity is the store’s, and this module honours it

OutboxWriter::claim_due moves rows to Dispatching under a worker identifier with skip-locked semantics, so two dispatchers claim disjoint sets. This module never bypasses it — it dispatches exactly what a claim returned — and it never invents an idempotency key: the one the command was admitted under travels on the row and is handed to the sender, so a remote that deduplicates can.

§Unknown outcomes are reconciled, not retried

A row in OutcomeUnknown is out of the dispatcher’s hands: only the application knows how to ask the remote system what happened. OutboxDispatcher::reconcile is the hook — it hands the stored record to an OutboxReconciler and settles the row with the answer, including putting it back in the queue when the remote is certain it never arrived.

Modules§

code
Stable codes this module records on a row it settled itself.

Structs§

DispatchConfig
How the dispatcher works (all of it optional, all of it conservative).
DispatchReport
What one sweep did.
OutboxDispatcher
Claims due outbox rows, sends them and settles each one.

Enums§

Dispatched
How one send ended, as the sender classifies it.
Reconciled
What a reconciler found out about a row whose outcome was unknown.

Traits§

OutboxReconciler
Asks the external system what happened to a row whose outcome is unknown (spec §16.5).
OutboxSender
Calls the external system for one outbox row.