Skip to main content

aion_store/
outbox.rs

1//! Durable outbox contract for store-backed fan-out dispatch.
2//!
3//! The outbox is a transactional staging table written in the same atomic batch as the
4//! workflow-history events that schedule fan-out activities (see
5//! [`crate::WritableEventStore::append`] and the libSQL `append_with_outbox` path). A separate,
6//! non-replayed dispatcher claims pending rows, dispatches them to connected workers, and marks
7//! them done or schedules a retry. This module declares only the storage contract; the dispatcher
8//! and Recorder wiring live outside the store crate.
9//!
10//! Idempotency is enforced at the database level: each row carries a `dispatch_key`
11//! (`"{workflow_id}:{ordinal}"`) under a `UNIQUE` constraint, so a re-issued append of the same
12//! fan-out batch silently ignores the duplicate rows rather than dispatching them twice.
13
14use aion_core::{Payload, RunId, WorkflowId};
15use async_trait::async_trait;
16use chrono::{DateTime, Utc};
17
18use crate::StoreError;
19
20/// Routing identity a row carries when no explicit value was staged: the `"default"` namespace and
21/// the `"default"` task queue. This is both the fresh-staging fallback (no SDK task-queue selection
22/// exists yet — NSTQ-4) and the legacy-NULL read-back value for rows persisted before the columns
23/// existed (NSTQ-2).
24///
25/// Aliased to [`aion_core::DEFAULT_TASK_QUEUE`] so the outbox-row default cannot drift from the
26/// canonical domain task-queue default; both the namespace and task-queue fallbacks resolve to the
27/// same `"default"` literal.
28pub const DEFAULT_OUTBOX_ROUTE: &str = aion_core::DEFAULT_TASK_QUEUE;
29
30/// Lifecycle state of an outbox row as the dispatcher drives it to a terminal outcome.
31///
32/// Rows are inserted `Pending`, transitioned to `Claimed` while a dispatcher holds them, and end
33/// in `Done` (dispatched and acknowledged) or `Failed` (retry budget exhausted). `Failed` is a
34/// dead-letter marker for operator inspection; the dispatcher never re-claims it.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum OutboxStatus {
37    /// Awaiting a dispatcher claim once `visible_after` has passed.
38    Pending,
39    /// Claimed by a dispatcher and in flight.
40    Claimed,
41    /// Dispatched and acknowledged; terminal.
42    Done,
43    /// Retry budget exhausted; terminal dead letter.
44    Failed,
45    /// Cancelled by workflow history before dispatch completed; terminal.
46    Cancelled,
47}
48
49impl std::fmt::Display for OutboxStatus {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.write_str(self.as_str())
52    }
53}
54
55impl OutboxStatus {
56    /// Returns the canonical lowercase token persisted in the `status` column.
57    #[must_use]
58    pub fn as_str(self) -> &'static str {
59        match self {
60            Self::Pending => "pending",
61            Self::Claimed => "claimed",
62            Self::Done => "done",
63            Self::Failed => "failed",
64            Self::Cancelled => "cancelled",
65        }
66    }
67
68    /// Parses a persisted `status` token back into an [`OutboxStatus`].
69    ///
70    /// # Errors
71    ///
72    /// Returns [`StoreError::Serialization`] when `value` is not one of the four canonical tokens.
73    pub fn parse_token(value: &str) -> Result<Self, StoreError> {
74        match value {
75            "pending" => Ok(Self::Pending),
76            "claimed" => Ok(Self::Claimed),
77            "done" => Ok(Self::Done),
78            "failed" => Ok(Self::Failed),
79            "cancelled" => Ok(Self::Cancelled),
80            other => Err(StoreError::Serialization(format!(
81                "unknown outbox status: {other}"
82            ))),
83        }
84    }
85}
86
87/// Pool scope for a node-affinity-aware outbox claim (LSUB-1a).
88///
89/// A scope restricts a claim to the rows servable by one worker pool: the `(namespace, task_queue)`
90/// the pool serves, plus the optional `node` locality of the claiming node. It is the additive,
91/// opt-in counterpart to the unscoped [`OutboxStore::claim_outbox_rows`] — passing no scope keeps
92/// the legacy single-server behaviour of claiming any visible row.
93///
94/// # Node predicate
95///
96/// `node` is the *claiming node's* id, not a row filter that demands an exact match. A row is in
97/// scope for node `N` when its own `node` affinity is **either** `Some(N)` (explicitly pinned to
98/// `N`) **or** `None` (unpinned — no affinity, servable by any node in the pool). Rows pinned to a
99/// *different* node `Some(M)` where `M != N` are excluded.
100///
101/// This matches the NODE-AFFINITY model where `node` on a row is OPTIONAL locality
102/// ([`OutboxRow::node`]): unpinned rows (`None`) are the genuine current behaviour — claimable by
103/// anyone in the pool — so a node-scoped claim must keep serving them, otherwise enabling affinity
104/// for some rows would silently strand every unpinned row. A `node: None` scope (a pool that
105/// advertises no locality) claims only unpinned rows, never another node's pinned rows.
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub struct ClaimScope {
108    /// Namespace the pool serves; only rows with this exact `namespace` are in scope.
109    pub namespace: String,
110    /// Task queue the pool serves; only rows with this exact `task_queue` are in scope.
111    pub task_queue: String,
112    /// Claiming node's locality id, or `None` for a pool that advertises no node affinity.
113    ///
114    /// `Some(n)` claims rows with `node == Some(n)` AND unpinned rows (`node == None`). `None` claims
115    /// only unpinned rows (`node == None`).
116    pub node: Option<String>,
117}
118
119impl ClaimScope {
120    /// Builds a scope for the `(namespace, task_queue)` pool with no node locality.
121    #[must_use]
122    pub fn new(namespace: impl Into<String>, task_queue: impl Into<String>) -> Self {
123        Self {
124            namespace: namespace.into(),
125            task_queue: task_queue.into(),
126            node: None,
127        }
128    }
129
130    /// Sets the claiming node's locality id on this scope.
131    #[must_use]
132    pub fn with_node(mut self, node: Option<String>) -> Self {
133        self.node = node;
134        self
135    }
136
137    /// Returns whether `row` is servable under this scope.
138    ///
139    /// True iff the namespace and task queue match exactly AND the node predicate holds: the row is
140    /// unpinned (`node == None`) or pinned to this scope's node (`row.node == self.node` when
141    /// `self.node` is `Some`). See the [type docs](ClaimScope#node-predicate) for the rationale.
142    #[must_use]
143    pub fn admits(&self, row: &OutboxRow) -> bool {
144        row.namespace == self.namespace
145            && row.task_queue == self.task_queue
146            && match (&self.node, &row.node) {
147                // Unpinned rows are servable by any node in the pool.
148                (_, None) => true,
149                // A pinned row is servable only by the node it is pinned to.
150                (Some(scope_node), Some(row_node)) => scope_node == row_node,
151                // A pool with no locality cannot serve another node's pinned row.
152                (None, Some(_)) => false,
153            }
154    }
155}
156
157/// One durable fan-out dispatch staged for a worker.
158///
159/// The row carries everything the out-of-band dispatcher needs to send the activity without
160/// reading workflow history: the originating workflow, the pinned `ordinal` within its fan-out
161/// range, the derived `dispatch_key` idempotency guard, the activity type, and the input payload.
162/// `attempt`, `visible_after`, `claimed_at`, and `status` track retry/backoff and claim state.
163/// `claimed_at` is set only while a row is [`OutboxStatus::Claimed`]; pending and terminal rows
164/// keep it `None` so stale-claim reconciliation only considers durable claimed rows.
165#[derive(Clone, Debug, PartialEq, Eq)]
166pub struct OutboxRow {
167    /// Database-level idempotency key, canonically `"{workflow_id}:{ordinal}"`.
168    pub dispatch_key: String,
169    /// Workflow that scheduled this fan-out activity.
170    pub workflow_id: WorkflowId,
171    /// Pinned ordinal of this activity within the workflow's fan-out range.
172    pub ordinal: u64,
173    /// Run that dispatched this ordinal; `None` for legacy rows (pre-RunId threading). Threaded so a
174    /// completion only resolves the run that issued it (continue-as-new safety, OBX-011).
175    pub run_id: Option<RunId>,
176    /// Workflow's durable isolation namespace — the correctness boundary the dispatched activity must
177    /// route within. Legacy rows (pre-NSTQ-2, persisted before the column existed) read back as the
178    /// `"default"` namespace. Carried on the row so the dispatcher routes via the workflow's real
179    /// namespace instead of inventing the server default (NSTQ-2).
180    pub namespace: String,
181    /// Pool/flavour selector within the namespace. There is no SDK-level task-queue selection yet
182    /// (NSTQ-4), so a freshly staged row carries the named `"default"` task queue; legacy rows
183    /// (pre-NSTQ-2) also read back as `"default"`. Carried on the row so the dispatcher routes via the
184    /// row's real selector (NSTQ-2).
185    pub task_queue: String,
186    /// OPTIONAL locality affinity within the `(namespace, task_queue)` pool. `None` = no affinity =
187    /// any worker in the pool (the genuine current behaviour: there is no SDK-level node selection
188    /// yet — NODE-4). `Some(node)` pins the dispatch to workers advertising that node id. Legacy
189    /// rows (pre-NODE-2, persisted before the column existed) read back as `None`: a NULL column is
190    /// "no affinity", NOT a sentinel string (NODE-2).
191    pub node: Option<String>,
192    /// Activity type the worker must execute.
193    pub activity_type: String,
194    /// Opaque activity input payload.
195    pub input: Payload,
196    /// Lifecycle state of this row.
197    pub status: OutboxStatus,
198    /// Zero-based dispatch attempt count; incremented on each retry.
199    pub attempt: u32,
200    /// Earliest instant at which this row becomes claimable (retry backoff fence).
201    pub visible_after: DateTime<Utc>,
202    /// Durable instant at which the row was claimed; absent unless `status` is `Claimed`.
203    pub claimed_at: Option<DateTime<Utc>>,
204    /// Whether this dead letter's infrastructure failure was DELIVERED to the owning workflow —
205    /// the durable judgment marker redrive is gated on.
206    ///
207    /// A dead-lettered row ([`OutboxStatus::Failed`]) has one of two very different meanings, and
208    /// nothing else on the row distinguishes them:
209    ///
210    /// - `false` — the workflow was **never told**. Either no delivery callback was installed, the
211    ///   callback found no live workflow to accept the failure, or the delivery itself errored. The
212    ///   workflow is still waiting on an activity that will never arrive; the work was never
213    ///   judged, so [`OutboxStore::redrive_outbox_row`] may return the row to the pending claim
214    ///   path.
215    /// - `true` — the failure reached the owning workflow, which has already reacted under its own
216    ///   retry/failure semantics, and that reaction is recorded history. Re-driving such a row
217    ///   would re-execute a possibly non-idempotent activity whose failure is already judged, so
218    ///   redrive REFUSES it ([`RedriveRefusal::AlreadyJudged`]) unless an operator explicitly
219    ///   forces the redrive with [`RedriveMode::Forced`].
220    ///
221    /// Only [`OutboxStore::record_outbox_failure_delivered`] ever sets it, and only on a row that
222    /// is already [`OutboxStatus::Failed`]. [`OutboxStore::fail_outbox_row`] clears it, so a
223    /// redriven row that dead-letters again starts a fresh judgment cycle. Rows persisted before
224    /// this field existed read back `false`: the pre-redrive dead-letter path had no failure
225    /// delivery at all, so "never told" is the historically accurate value.
226    pub failure_delivered: bool,
227}
228
229/// Whether a redrive may resurrect a dead letter whose failure was already judged.
230///
231/// [`Self::Eligible`] is the ONLY safe default: it redrives exclusively rows whose failure
232/// delivery did not reach the workflow. [`Self::Forced`] is an explicit operator override that
233/// knowingly re-executes an activity whose failure is recorded history; callers must log it
234/// loudly.
235#[derive(Clone, Copy, Debug, PartialEq, Eq)]
236pub enum RedriveMode {
237    /// Redrive only an un-judged dead letter (`failure_delivered == false`).
238    Eligible,
239    /// Redrive even a judged dead letter (`failure_delivered == true`) — operator override.
240    Forced,
241}
242
243impl RedriveMode {
244    /// Whether this mode admits a dead letter whose failure was already delivered.
245    #[must_use]
246    pub fn admits_judged(self) -> bool {
247        matches!(self, Self::Forced)
248    }
249}
250
251/// Terminal outcome of [`OutboxStore::redrive_outbox_row`].
252///
253/// Never a silent no-op: either the row moved back to the pending claim path (and the post-state
254/// row is returned), or the store reports exactly WHY it refused.
255#[derive(Clone, Debug, PartialEq, Eq)]
256pub enum RedriveOutcome {
257    /// The dead letter returned to [`OutboxStatus::Pending`].
258    Redriven {
259        /// The row's post-state: `Pending`, attempt reset, judgment marker cleared.
260        row: Box<OutboxRow>,
261        /// Whether the row's failure had ALREADY been delivered to the workflow before this
262        /// redrive moved it — i.e. whether this was a [`RedriveMode::Forced`] override of a
263        /// judged dead letter.
264        ///
265        /// Reported from the store's own atomic pre-state because the post-state row always has
266        /// the marker cleared, so it could not otherwise be observed. Callers MUST log a `true`
267        /// loudly: it means an activity whose failure is recorded history is about to run again.
268        was_judged: bool,
269    },
270    /// The row was not eligible; nothing was written.
271    Refused(RedriveRefusal),
272}
273
274/// Typed reason a redrive was refused, so no caller has to infer one from an empty result.
275#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
276pub enum RedriveRefusal {
277    /// No row exists for the supplied `dispatch_key`.
278    #[error("no outbox row exists for dispatch key {dispatch_key}")]
279    NoSuchRow {
280        /// The dispatch key that matched nothing.
281        dispatch_key: String,
282    },
283    /// The row exists but is not a dead letter, so there is nothing to redrive. `Done`,
284    /// `Cancelled`, and a live `Pending`/`Claimed` row all land here and are left untouched.
285    #[error(
286        "outbox row {dispatch_key} is '{status}', not a dead letter; only a failed row may be redriven"
287    )]
288    NotDeadLettered {
289        /// The dispatch key that was targeted.
290        dispatch_key: String,
291        /// The status the row is actually in.
292        status: OutboxStatus,
293    },
294    /// The row IS a dead letter, but its failure was already delivered to the workflow, which has
295    /// reacted; redriving would re-execute an activity whose failure is recorded history. Only
296    /// [`RedriveMode::Forced`] overrides this.
297    #[error(
298        "outbox row {dispatch_key} dead-lettered and its failure was already delivered to the \
299         workflow; redriving it would re-execute an activity whose failure is recorded history"
300    )]
301    AlreadyJudged {
302        /// The dispatch key that was targeted.
303        dispatch_key: String,
304    },
305}
306
307impl OutboxRow {
308    /// Builds the canonical `dispatch_key` for a `(workflow_id, ordinal)` pair.
309    ///
310    /// This is the single source of truth for the idempotency key format so the append path and any
311    /// completion-routing lookups agree byte-for-byte.
312    #[must_use]
313    pub fn dispatch_key_for(workflow_id: &WorkflowId, ordinal: u64) -> String {
314        format!("{workflow_id}:{ordinal}")
315    }
316
317    /// Constructs a fresh `Pending` row for `(workflow_id, ordinal)` with attempt zero.
318    ///
319    /// `visible_after` is set to `now` so the row is immediately claimable. The `dispatch_key` is
320    /// derived via [`OutboxRow::dispatch_key_for`].
321    #[must_use]
322    pub fn pending(
323        workflow_id: WorkflowId,
324        ordinal: u64,
325        activity_type: String,
326        input: Payload,
327        now: DateTime<Utc>,
328    ) -> Self {
329        let dispatch_key = Self::dispatch_key_for(&workflow_id, ordinal);
330        Self {
331            dispatch_key,
332            workflow_id,
333            ordinal,
334            run_id: None,
335            namespace: String::from(DEFAULT_OUTBOX_ROUTE),
336            task_queue: String::from(DEFAULT_OUTBOX_ROUTE),
337            node: None,
338            activity_type,
339            input,
340            status: OutboxStatus::Pending,
341            attempt: 0,
342            visible_after: now,
343            claimed_at: None,
344            failure_delivered: false,
345        }
346    }
347
348    /// Sets the dispatching run on this row (the run that owns this ordinal).
349    #[must_use]
350    pub fn with_run_id(mut self, run_id: Option<RunId>) -> Self {
351        self.run_id = run_id;
352        self
353    }
354
355    /// Sets the workflow's durable isolation namespace on this row (the routing correctness boundary).
356    #[must_use]
357    pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
358        self.namespace = namespace.into();
359        self
360    }
361
362    /// Sets the pool/flavour selector (task queue) on this row.
363    #[must_use]
364    pub fn with_task_queue(mut self, task_queue: impl Into<String>) -> Self {
365        self.task_queue = task_queue.into();
366        self
367    }
368
369    /// Sets the OPTIONAL node affinity on this row. `None` = no affinity (any worker in the pool).
370    #[must_use]
371    pub fn with_node(mut self, node: Option<String>) -> Self {
372        self.node = node;
373        self
374    }
375}
376
377/// Durable staging and claim contract for store-backed fan-out dispatch.
378///
379/// Implementations append outbox rows transactionally with workflow-history events, hand pending
380/// rows to a single dispatcher under the single-writer model, and record terminal outcomes. All
381/// methods are idempotency-aware: appending a duplicate `dispatch_key` is silently ignored, and the
382/// completion/retry/fail transitions key off `dispatch_key`.
383#[async_trait]
384pub trait OutboxStore: Send + Sync + 'static {
385    /// Inserts `rows` into the outbox, silently ignoring any whose `dispatch_key` already exists.
386    ///
387    /// This is the standalone (non-atomic-with-events) append used for tests and out-of-band
388    /// staging. The atomic-with-history append lives on the concrete store as `append_with_outbox`.
389    /// Duplicate keys are ignored via `INSERT OR IGNORE`, preserving at-most-once dispatch.
390    ///
391    /// # Errors
392    ///
393    /// Returns [`StoreError::Backend`] for backend boundary failures and
394    /// [`StoreError::Serialization`] when a row cannot be encoded.
395    async fn append_outbox_batch(&self, rows: &[OutboxRow]) -> Result<(), StoreError>;
396
397    /// Atomically claims up to `limit` pending rows whose `visible_after` has passed.
398    ///
399    /// Claimed rows are transitioned to [`OutboxStatus::Claimed`] and returned. Under the
400    /// single-writer IMMEDIATE model this is the SQLite-equivalent of `SELECT ... FOR UPDATE SKIP
401    /// LOCKED`: no two dispatchers observe the same pending row as claimable.
402    ///
403    /// # Errors
404    ///
405    /// Returns [`StoreError::Backend`] for backend boundary failures and
406    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
407    async fn claim_outbox_rows(&self, limit: u32) -> Result<Vec<OutboxRow>, StoreError>;
408
409    /// Atomically claims up to `limit` due pending rows whose owning workflow is
410    /// NOT in `held` (the pause dispatch-hold, #204).
411    ///
412    /// This is the pause-aware counterpart to [`OutboxStore::claim_outbox_rows`]:
413    /// a row whose `workflow_id` is in `held` is never selected, so it stays
414    /// [`OutboxStatus::Pending`] for the entire paused window — it is NEVER
415    /// transitioned to [`OutboxStatus::Claimed`] and released, so no un-claim
416    /// primitive is needed. Release is purely resume (the workflow leaves `held`)
417    /// plus the ordinary interval/wake sweep. With an empty `held` set this is
418    /// byte-identical to [`OutboxStore::claim_outbox_rows`].
419    ///
420    /// The default implementation ignores `held` and delegates to
421    /// [`OutboxStore::claim_outbox_rows`]: a test-double store that never pauses
422    /// is unaffected. The bundled durable backends (libSQL, haematite) override
423    /// it to apply the exclusion inside the same atomic, single-writer claim so a
424    /// held row is never claimed even under concurrent sweeps.
425    ///
426    /// # Errors
427    ///
428    /// Returns [`StoreError::Backend`] for backend boundary failures and
429    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
430    async fn claim_outbox_rows_excluding(
431        &self,
432        limit: u32,
433        held: &std::collections::HashSet<WorkflowId>,
434    ) -> Result<Vec<OutboxRow>, StoreError> {
435        let _ = held;
436        self.claim_outbox_rows(limit).await
437    }
438
439    /// Atomically claims up to `limit` pending rows that are due AND in `scope` (LSUB-1a).
440    ///
441    /// This is the node-affinity-aware counterpart to [`OutboxStore::claim_outbox_rows`]: it adds a
442    /// `(namespace, task_queue, node)` predicate to the same atomic, single-writer claim and is
443    /// otherwise byte-identical (same due/order/limit/claim semantics). The unscoped method is left
444    /// exactly as it was — passing no scope is still "claim any visible row" — so the existing
445    /// single-server poll loop is unaffected.
446    ///
447    /// A row is in scope when its `namespace` and `task_queue` match `scope` exactly and the node
448    /// predicate holds: the row is unpinned (`node == None`, servable by any node in the pool) or
449    /// pinned to `scope.node`. See [`ClaimScope`] for the full node-predicate rationale.
450    ///
451    /// # Errors
452    ///
453    /// Returns [`StoreError::Backend`] for backend boundary failures and
454    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
455    async fn claim_outbox_rows_scoped(
456        &self,
457        scope: &ClaimScope,
458        limit: u32,
459    ) -> Result<Vec<OutboxRow>, StoreError>;
460
461    /// Atomically claims up to `limit` due pending rows that are in `scope` AND whose
462    /// owning workflow is NOT in `held` (the pause dispatch-hold, #204).
463    ///
464    /// This is the pause-aware counterpart to [`OutboxStore::claim_outbox_rows_scoped`]:
465    /// it is the scoped (backpressure / node-affinity) claim path with the same
466    /// held-exclusion as [`OutboxStore::claim_outbox_rows_excluding`]. The production
467    /// outbox dispatcher runs under keyed backpressure and therefore claims through the
468    /// SCOPED path, so the pause hold MUST be applied here too — otherwise a held row
469    /// would still be claimed and dispatched under backpressure. A held row is never
470    /// flipped to [`OutboxStatus::Claimed`]; it stays [`OutboxStatus::Pending`] for the
471    /// whole paused window and release is purely resume plus the ordinary sweep.
472    ///
473    /// The default implementation ignores `held` and delegates to
474    /// [`OutboxStore::claim_outbox_rows_scoped`]: a test-double store that never pauses
475    /// is unaffected. The bundled durable backends (libSQL, haematite) override it to
476    /// apply the exclusion inside the same atomic, single-writer claim.
477    ///
478    /// # Errors
479    ///
480    /// Returns [`StoreError::Backend`] for backend boundary failures and
481    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
482    async fn claim_outbox_rows_scoped_excluding(
483        &self,
484        scope: &ClaimScope,
485        limit: u32,
486        held: &std::collections::HashSet<WorkflowId>,
487    ) -> Result<Vec<OutboxRow>, StoreError> {
488        let _ = held;
489        self.claim_outbox_rows_scoped(scope, limit).await
490    }
491
492    /// Returns up to `limit` STALE claimed rows — `status` is [`OutboxStatus::Claimed`] and the
493    /// durable `claimed_at` is older than `older_than` — WITHOUT transitioning them (#253).
494    ///
495    /// This is the read-only probe half of stale-claim reconciliation: it selects exactly the rows
496    /// [`OutboxStore::rearm_stale_claimed_outbox_rows`] would re-arm (same status/`claimed_at`
497    /// predicate, same `claimed_at ASC, dispatch_key ASC` order, same `NULL claimed_at` exclusion)
498    /// so the reconciler can project each candidate workflow's liveness FIRST and settle rows whose
499    /// workflow is already terminal instead of re-arming a dispatch nobody may deliver (the
500    /// incident's zombie-round hole). Claims and mutates nothing.
501    ///
502    /// There is deliberately no silently-empty default: a store that cannot enumerate stale claims
503    /// cannot host the liveness-gated reconciler, and pretending "no stale rows" would re-open the
504    /// ungated re-arm. Outbox-bearing backends must implement it.
505    ///
506    /// # Errors
507    ///
508    /// Returns [`StoreError::Backend`] for backend boundary failures (including a store that has not
509    /// implemented this probe) and [`StoreError::Serialization`] when a stored row cannot be decoded.
510    async fn list_stale_claimed_outbox_rows(
511        &self,
512        older_than: DateTime<Utc>,
513        limit: u32,
514    ) -> Result<Vec<OutboxRow>, StoreError> {
515        let _ = (older_than, limit);
516        Err(StoreError::Backend(String::from(
517            "this outbox store does not support the stale-claim liveness probe; \
518             refusing to report an empty stale set (override OutboxStore::list_stale_claimed_outbox_rows)",
519        )))
520    }
521
522    /// Returns the distinct workflow ids owning at least one UNSETTLED row — `status` is
523    /// [`OutboxStatus::Pending`] or [`OutboxStatus::Claimed`] — scoped to this node's owned shards
524    /// like every other outbox enumeration (#253).
525    ///
526    /// This is the boot/adoption sweep's enumeration primitive: after a restart (or a shard
527    /// adoption) the server projects each returned workflow's status once and settles the rows of
528    /// terminal workflows via [`OutboxStore::cancel_outbox_rows_for_workflow`], closing the window
529    /// where a workflow reached its terminal without its rows being settled (a settle-hook failure,
530    /// or a terminal recorded by a node that died before settling). Bounded by the number of
531    /// workflows with live rows; read-only.
532    ///
533    /// No silently-empty default, for the same reason as
534    /// [`OutboxStore::list_stale_claimed_outbox_rows`]: an empty answer from a store that never
535    /// looked would silently disable the boot repair.
536    ///
537    /// # Errors
538    ///
539    /// Returns [`StoreError::Backend`] for backend boundary failures (including a store that has not
540    /// implemented this enumeration) and [`StoreError::Serialization`] when a stored row cannot be
541    /// decoded.
542    async fn list_unsettled_outbox_workflow_ids(&self) -> Result<Vec<WorkflowId>, StoreError> {
543        Err(StoreError::Backend(String::from(
544            "this outbox store does not support unsettled-workflow enumeration; \
545             refusing to report an empty set (override OutboxStore::list_unsettled_outbox_workflow_ids)",
546        )))
547    }
548
549    /// Idempotently settles EVERY live ([`OutboxStatus::Pending`] or [`OutboxStatus::Claimed`]) row
550    /// of `workflow_id` to [`OutboxStatus::Cancelled`], returning the settled `dispatch_key`s
551    /// (#253).
552    ///
553    /// The [`OutboxStore`]-facing twin of
554    /// [`crate::WritableEventStore::settle_workflow_outbox_rows_cancelled`] (concrete stores share
555    /// one implementation), exposed here so the server-side boot sweep and the stale-claim
556    /// reconciler — which hold an outbox-store handle, not a writer — can settle a terminal
557    /// workflow's rows. Terminal rows (`Done`/`Failed`/`Cancelled`) are never touched, and
558    /// [`crate::WritableEventStore::rearm_outbox_pending`] still supersedes the settle on reopen.
559    ///
560    /// No silently-succeeding default: a store that cannot settle must refuse loudly rather than
561    /// leave a terminal workflow's rows claimable.
562    ///
563    /// # Errors
564    ///
565    /// Returns [`StoreError::Backend`] for backend boundary failures (including a store that has not
566    /// implemented the settle) and [`StoreError::Serialization`] when a stored row cannot be
567    /// decoded.
568    async fn cancel_outbox_rows_for_workflow(
569        &self,
570        workflow_id: &WorkflowId,
571    ) -> Result<Vec<String>, StoreError> {
572        let _ = workflow_id;
573        Err(StoreError::Backend(String::from(
574            "this outbox store does not support workflow-terminal settlement; \
575             refusing to no-op a settle (override OutboxStore::cancel_outbox_rows_for_workflow)",
576        )))
577    }
578
579    /// Re-arms stale claimed rows so a live dispatcher can claim them again without restart.
580    ///
581    /// Implementations atomically select up to `limit` rows whose `status` is
582    /// [`OutboxStatus::Claimed`] and whose durable `claimed_at` timestamp is older than
583    /// `older_than`, then transition only those rows back to [`OutboxStatus::Pending`] with
584    /// `visible_after` set to the supplied instant. The existing `attempt` value is preserved and
585    /// `claimed_at` is cleared. Keys in `excluded` remain claimed even when stale; this atomically
586    /// protects rows whose live delivery task still owns them from reconciliation races. Rows in
587    /// `Done` or `Failed` are terminal and must never be touched. Rows in `Cancelled` are also
588    /// terminal and must never be touched.
589    ///
590    /// Claimed rows without a durable `claimed_at` value are deliberately ignored: the caller asked
591    /// for rows older than a supplied instant, and `NULL` cannot satisfy that predicate safely.
592    ///
593    /// # Errors
594    ///
595    /// Returns [`StoreError::Backend`] for backend boundary failures and
596    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
597    async fn rearm_stale_claimed_outbox_rows(
598        &self,
599        older_than: DateTime<Utc>,
600        visible_after: DateTime<Utc>,
601        limit: u32,
602        excluded: &std::collections::HashSet<String>,
603    ) -> Result<Vec<OutboxRow>, StoreError>;
604
605    /// Marks the row identified by `dispatch_key` as [`OutboxStatus::Done`].
606    ///
607    /// A `dispatch_key` with no matching row is a no-op (the dedup guard may have removed it), not
608    /// an error.
609    ///
610    /// # Errors
611    ///
612    /// Returns [`StoreError::Backend`] for backend boundary failures.
613    async fn complete_outbox_row(&self, dispatch_key: &str) -> Result<(), StoreError>;
614
615    /// Returns the row identified by `dispatch_key` to [`OutboxStatus::Pending`] for retry.
616    ///
617    /// Sets `attempt` to `next_attempt` and `visible_after` to `visible_after` so the dispatcher
618    /// honours backoff before re-claiming. An absent `dispatch_key` is a no-op.
619    ///
620    /// # Errors
621    ///
622    /// Returns [`StoreError::Backend`] for backend boundary failures.
623    async fn retry_outbox_row(
624        &self,
625        dispatch_key: &str,
626        next_attempt: u32,
627        visible_after: DateTime<Utc>,
628    ) -> Result<(), StoreError>;
629
630    /// Marks the row identified by `dispatch_key` as [`OutboxStatus::Failed`] (dead letter).
631    ///
632    /// [`OutboxRow::failure_delivered`] is CLEARED by this transition: dead-lettering opens a fresh
633    /// judgment cycle, so a row that was redriven after an earlier judged dead letter never carries
634    /// the stale marker into its new one.
635    ///
636    /// An absent `dispatch_key` is a no-op.
637    ///
638    /// # Errors
639    ///
640    /// Returns [`StoreError::Backend`] for backend boundary failures.
641    async fn fail_outbox_row(&self, dispatch_key: &str) -> Result<(), StoreError>;
642
643    /// Durably records that a dead letter's failure REACHED the owning workflow, returning whether
644    /// the marker was written.
645    ///
646    /// This is the write half of the judgment distinction documented on
647    /// [`OutboxRow::failure_delivered`]: the dispatcher calls it immediately after a delivery
648    /// callback accepted the failure into a live workflow, and redrive then refuses that row by
649    /// default. Status-guarded inside the backend's own operation: ONLY a row already in
650    /// [`OutboxStatus::Failed`] is marked, so a concurrent reopen/re-arm that moved the row on
651    /// cannot be silently annotated.
652    ///
653    /// Returns `false` when nothing was marked (no such row, or the row is no longer a dead
654    /// letter). That is a genuine signal, not a no-op: callers MUST log it, because it means the
655    /// row moved underneath the dead-letter path.
656    ///
657    /// No silently-succeeding default: a store that cannot record the marker would leave every
658    /// judged dead letter looking redrivable.
659    ///
660    /// # Errors
661    ///
662    /// Returns [`StoreError::Backend`] for backend boundary failures (including a store that has
663    /// not implemented the marker) and [`StoreError::Serialization`] when a stored row cannot be
664    /// decoded.
665    async fn record_outbox_failure_delivered(
666        &self,
667        dispatch_key: &str,
668    ) -> Result<bool, StoreError> {
669        let _ = dispatch_key;
670        Err(StoreError::Backend(String::from(
671            "this outbox store does not support the dead-letter judgment marker; \
672             refusing to silently drop it (override OutboxStore::record_outbox_failure_delivered)",
673        )))
674    }
675
676    /// Returns every dead-lettered ([`OutboxStatus::Failed`]) row of `workflow_id`, ordered by
677    /// `ordinal`.
678    ///
679    /// The operator's discovery primitive for redrive: a workflow that is still `Running` while an
680    /// activity never returned has its evidence here, each row carrying
681    /// [`OutboxRow::failure_delivered`] so the operator can see which dead letters are redrivable
682    /// and which were already judged. Read-only; scoped to this node's owned shards like every
683    /// other outbox enumeration.
684    ///
685    /// No silently-empty default, for the same reason as
686    /// [`OutboxStore::list_stale_claimed_outbox_rows`]: an empty answer from a store that never
687    /// looked would tell an operator there is nothing to redrive.
688    ///
689    /// # Errors
690    ///
691    /// Returns [`StoreError::Backend`] for backend boundary failures (including a store that has
692    /// not implemented the enumeration) and [`StoreError::Serialization`] when a stored row cannot
693    /// be decoded.
694    async fn list_dead_lettered_outbox_rows(
695        &self,
696        workflow_id: &WorkflowId,
697    ) -> Result<Vec<OutboxRow>, StoreError> {
698        let _ = workflow_id;
699        Err(StoreError::Backend(String::from(
700            "this outbox store does not support dead-letter enumeration; \
701             refusing to report an empty set (override OutboxStore::list_dead_lettered_outbox_rows)",
702        )))
703    }
704
705    /// Returns a DEAD-LETTERED row to the pending claim path, or reports why it refused.
706    ///
707    /// The transition is status-guarded inside the backend's own atomic operation, exactly like
708    /// [`OutboxStore::rearm_stale_claimed_outbox_rows`]: a row moves ONLY from
709    /// [`OutboxStatus::Failed`]. A [`OutboxStatus::Done`], [`OutboxStatus::Cancelled`], or live
710    /// [`OutboxStatus::Pending`]/[`OutboxStatus::Claimed`] row is never touched — it is refused
711    /// with [`RedriveRefusal::NotDeadLettered`], so a redrive can never resurrect a completed,
712    /// settled, or in-flight dispatch.
713    ///
714    /// A redriven row returns to [`OutboxStatus::Pending`] with its attempt budget RESET to zero
715    /// (the retry budget was spent on infrastructure failures the workflow never learned about),
716    /// `visible_after` set to the supplied instant, `claimed_at` cleared, and
717    /// [`OutboxRow::failure_delivered`] cleared.
718    ///
719    /// # Judgment gate
720    ///
721    /// `mode` decides what happens to a dead letter whose failure WAS delivered
722    /// ([`OutboxRow::failure_delivered`]): [`RedriveMode::Eligible`] refuses it
723    /// ([`RedriveRefusal::AlreadyJudged`]) because the workflow already reacted to that failure and
724    /// re-running the activity would re-execute non-idempotent work behind recorded history;
725    /// [`RedriveMode::Forced`] redrives it anyway as an explicit operator override that the caller
726    /// must log loudly.
727    ///
728    /// No silently-succeeding default: a store that cannot redrive must refuse loudly rather than
729    /// let an operator believe work was re-queued.
730    ///
731    /// # Errors
732    ///
733    /// Returns [`StoreError::Backend`] for backend boundary failures (including a store that has
734    /// not implemented redrive) and [`StoreError::Serialization`] when a stored row cannot be
735    /// decoded. An ineligible row is NOT an error: it comes back as
736    /// [`RedriveOutcome::Refused`] with a typed reason.
737    async fn redrive_outbox_row(
738        &self,
739        dispatch_key: &str,
740        visible_after: DateTime<Utc>,
741        mode: RedriveMode,
742    ) -> Result<RedriveOutcome, StoreError> {
743        let _ = (dispatch_key, visible_after, mode);
744        Err(StoreError::Backend(String::from(
745            "this outbox store does not support dead-letter redrive; \
746             refusing to report a redrive that never happened (override OutboxStore::redrive_outbox_row)",
747        )))
748    }
749
750    /// Returns the count of in-flight outbox rows for `namespace` (CP2-Q1.5).
751    ///
752    /// "In-flight" is the dispatched-but-not-terminal set: rows whose `status` is
753    /// [`OutboxStatus::Pending`] OR [`OutboxStatus::Claimed`]. Terminal rows
754    /// ([`OutboxStatus::Done`], [`OutboxStatus::Failed`], [`OutboxStatus::Cancelled`]) are excluded.
755    ///
756    /// This is the durable, restart-correct quota source that replaces the in-memory
757    /// `inflight_activities` gauge proven dead in P2-Q0 (see `docs/design/CONTROL-PLANE-PHASE-2.md`
758    /// §3.3/§8). Because it counts durable rows, the count survives a restart, and because a
759    /// `Claimed` row is in-flight, a row that dispatched but whose `mark_done` failed (the
760    /// stuck-`Claimed` case) is still counted — it has not reached a terminal outcome and the worker
761    /// may still be running it. The count is strictly scoped to `namespace`: rows in any other
762    /// namespace are never included.
763    ///
764    /// Nothing consumes this yet (P2-Q2 will); it is a pure additive store query with no behaviour
765    /// change.
766    ///
767    /// # Errors
768    ///
769    /// Returns [`StoreError::Backend`] for backend boundary failures and
770    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
771    async fn count_inflight_outbox_rows(&self, namespace: &str) -> Result<u64, StoreError>;
772
773    /// Returns the count of CLAIMED outbox rows for `namespace` (CP2-Q2).
774    ///
775    /// "Claimed" is the *concurrently executing* set: rows in [`OutboxStatus::Claimed`] — dispatched
776    /// to a worker and not yet terminal. This is deliberately NARROWER than
777    /// [`OutboxStore::count_inflight_outbox_rows`], which also counts [`OutboxStatus::Pending`]
778    /// backlog: a tenant sitting on a large Pending backlog has a large *in-flight* count but a small
779    /// *claimed* count, and it is the CLAIMED count — concurrent executing activities — that the
780    /// keyed-backpressure ceiling caps (CP-Phase-2 §3.1 as corrected). Counting Pending+Claimed for
781    /// headroom would wedge a tenant against its own backlog: it could never claim the Pending rows
782    /// that make up the count. So headroom is `per_node_ceiling − claimed`, never `… − inflight`.
783    ///
784    /// A stuck-`Claimed` row (dispatched but `mark_done` never landed, `outbox_dispatcher` §) is
785    /// still `Claimed` and so still counts — the worker may still be executing it, so it correctly
786    /// occupies a concurrency slot. The count is strictly scoped to `namespace`: rows in any other
787    /// namespace are never included.
788    ///
789    /// # Errors
790    ///
791    /// Returns [`StoreError::Backend`] for backend boundary failures and
792    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
793    async fn count_claimed_outbox_rows(&self, namespace: &str) -> Result<u64, StoreError>;
794
795    /// Counts the CLAIMED outbox rows for each namespace in `namespaces`, in ONE pass (CP2-Q2 perf).
796    ///
797    /// Same semantics as calling [`OutboxStore::count_claimed_outbox_rows`] once per namespace — the
798    /// CLAIMED-only ([`OutboxStatus::Claimed`]), owned-shard-scoped concurrent-executing count that
799    /// feeds the keyed-backpressure headroom — but collapsed into a single scan of the owned-shard
800    /// set instead of N repeated scans over the same rows (the N+1 the per-sweep planner would
801    /// otherwise incur, one full scan per active namespace). The returned map has EXACTLY one entry
802    /// per requested namespace: a namespace with no claimed rows maps to `0`, so the caller can index
803    /// it unconditionally. Namespaces not in `namespaces` are never counted (nor returned).
804    ///
805    /// The default implementation preserves the contract by delegating to the per-namespace method
806    /// (an honest, correct fallback for any store that has not specialised the single-scan form); the
807    /// bundled stores override it with a genuine one-pass scan / grouped query.
808    ///
809    /// # Errors
810    ///
811    /// Returns [`StoreError::Backend`] for backend boundary failures and
812    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
813    async fn count_claimed_outbox_rows_by_namespace(
814        &self,
815        namespaces: &[&str],
816    ) -> Result<std::collections::BTreeMap<String, u64>, StoreError> {
817        let mut counts = std::collections::BTreeMap::new();
818        for namespace in namespaces {
819            let count = self.count_claimed_outbox_rows(namespace).await?;
820            counts.insert((*namespace).to_owned(), count);
821        }
822        Ok(counts)
823    }
824
825    /// Enumerates the distinct `(namespace, task_queue, node)` routes that currently have at least
826    /// one CLAIMABLE pending row — a row whose `status` is [`OutboxStatus::Pending`] and whose
827    /// `visible_after` fence has passed (CP2-Q2).
828    ///
829    /// This is the enumeration primitive the keyed-backpressure dispatcher round-robins over: it
830    /// cannot ask [`OutboxStore::claim_outbox_rows_scoped`] (which needs a *specific*
831    /// [`ClaimScope`]) to "claim across all namespaces", so it first probes which routes have work
832    /// and then issues one scoped, headroom-capped claim per route. Each returned [`ClaimScope`]
833    /// carries the exact `(namespace, task_queue, node)` of pending rows, so a subsequent
834    /// `claim_outbox_rows_scoped` with that scope claims those rows (and any unpinned rows in the
835    /// same pool — see [`ClaimScope`]). A route with only future-fenced (`visible_after > now`) or
836    /// terminal rows is NOT returned: there is nothing claimable to dispatch.
837    ///
838    /// The probe is read-only and claims nothing; it only shapes which scopes the dispatcher then
839    /// claims under. On a node that owns a shard subset, only routes with claimable rows on owned
840    /// shards are returned (the same owned-shard scoping as the claim path), so the per-node round
841    /// naturally sees only its proportional slice of each tenant's work.
842    ///
843    /// # Errors
844    ///
845    /// Returns [`StoreError::Backend`] for backend boundary failures and
846    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
847    async fn pending_outbox_routes(&self) -> Result<Vec<ClaimScope>, StoreError>;
848}
849
850#[cfg(test)]
851mod tests {
852    use std::sync::Arc;
853
854    use aion_core::{ContentType, Payload, WorkflowId};
855    use chrono::Utc;
856
857    use super::{ClaimScope, OutboxRow, OutboxStatus, OutboxStore};
858
859    #[test]
860    fn outbox_store_is_object_safe() {
861        let _: Option<Arc<dyn OutboxStore>> = None;
862    }
863
864    fn row(namespace: &str, task_queue: &str, node: Option<&str>) -> OutboxRow {
865        OutboxRow::pending(
866            WorkflowId::new_v4(),
867            0,
868            String::from("charge"),
869            Payload::new(ContentType::Json, b"{}".to_vec()),
870            Utc::now(),
871        )
872        .with_namespace(namespace)
873        .with_task_queue(task_queue)
874        .with_node(node.map(ToOwned::to_owned))
875    }
876
877    #[test]
878    fn scope_admits_matching_namespace_task_queue_and_unpinned_or_matching_node() {
879        let scope = ClaimScope::new("remote", "gpu").with_node(Some("box-7".to_owned()));
880        // Pinned to the scope's node: admitted.
881        assert!(scope.admits(&row("remote", "gpu", Some("box-7"))));
882        // Unpinned (no affinity): admitted by any node in the pool.
883        assert!(scope.admits(&row("remote", "gpu", None)));
884    }
885
886    #[test]
887    fn scope_rejects_other_namespace_task_queue_or_pinned_to_other_node() {
888        let scope = ClaimScope::new("remote", "gpu").with_node(Some("box-7".to_owned()));
889        // Wrong namespace.
890        assert!(!scope.admits(&row("default", "gpu", None)));
891        // Wrong task queue.
892        assert!(!scope.admits(&row("remote", "cpu", None)));
893        // Pinned to a different node.
894        assert!(!scope.admits(&row("remote", "gpu", Some("box-9"))));
895    }
896
897    #[test]
898    fn node_less_scope_admits_only_unpinned_rows() {
899        let scope = ClaimScope::new("remote", "gpu");
900        assert!(scope.admits(&row("remote", "gpu", None)));
901        // A node-less pool cannot serve a row pinned to a specific node.
902        assert!(!scope.admits(&row("remote", "gpu", Some("box-7"))));
903    }
904
905    #[test]
906    fn status_tokens_round_trip() -> Result<(), crate::StoreError> {
907        for status in [
908            OutboxStatus::Pending,
909            OutboxStatus::Claimed,
910            OutboxStatus::Done,
911            OutboxStatus::Failed,
912            OutboxStatus::Cancelled,
913        ] {
914            let parsed = OutboxStatus::parse_token(status.as_str())?;
915            assert_eq!(parsed, status);
916        }
917        Ok(())
918    }
919
920    #[test]
921    fn unknown_status_token_is_rejected() {
922        assert!(OutboxStatus::parse_token("nope").is_err());
923    }
924
925    #[test]
926    fn dispatch_key_is_workflow_id_colon_ordinal() {
927        let workflow_id = aion_core::WorkflowId::new_v4();
928        let key = OutboxRow::dispatch_key_for(&workflow_id, 7);
929        assert_eq!(key, format!("{workflow_id}:7"));
930    }
931}