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:
Dispatched | What the row becomes | Why |
|---|---|---|
Completed | Completed | the remote confirmed |
Retryable | Pending with a backoff, or Failed once the attempts are spent | the request demonstrably did not arrive |
Permanent | Failed | the remote refused, and will refuse again |
Unknown | OutcomeUnknown | the 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§
- Dispatch
Config - How the dispatcher works (all of it optional, all of it conservative).
- Dispatch
Report - What one sweep did.
- Outbox
Dispatcher - 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§
- Outbox
Reconciler - Asks the external system what happened to a row whose outcome is unknown (spec §16.5).
- Outbox
Sender - Calls the external system for one outbox row.