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 OutboxStatus {
50    /// Returns the canonical lowercase token persisted in the `status` column.
51    #[must_use]
52    pub fn as_str(self) -> &'static str {
53        match self {
54            Self::Pending => "pending",
55            Self::Claimed => "claimed",
56            Self::Done => "done",
57            Self::Failed => "failed",
58            Self::Cancelled => "cancelled",
59        }
60    }
61
62    /// Parses a persisted `status` token back into an [`OutboxStatus`].
63    ///
64    /// # Errors
65    ///
66    /// Returns [`StoreError::Serialization`] when `value` is not one of the four canonical tokens.
67    pub fn parse_token(value: &str) -> Result<Self, StoreError> {
68        match value {
69            "pending" => Ok(Self::Pending),
70            "claimed" => Ok(Self::Claimed),
71            "done" => Ok(Self::Done),
72            "failed" => Ok(Self::Failed),
73            "cancelled" => Ok(Self::Cancelled),
74            other => Err(StoreError::Serialization(format!(
75                "unknown outbox status: {other}"
76            ))),
77        }
78    }
79}
80
81/// Pool scope for a node-affinity-aware outbox claim (LSUB-1a).
82///
83/// A scope restricts a claim to the rows servable by one worker pool: the `(namespace, task_queue)`
84/// the pool serves, plus the optional `node` locality of the claiming node. It is the additive,
85/// opt-in counterpart to the unscoped [`OutboxStore::claim_outbox_rows`] — passing no scope keeps
86/// the legacy single-server behaviour of claiming any visible row.
87///
88/// # Node predicate
89///
90/// `node` is the *claiming node's* id, not a row filter that demands an exact match. A row is in
91/// scope for node `N` when its own `node` affinity is **either** `Some(N)` (explicitly pinned to
92/// `N`) **or** `None` (unpinned — no affinity, servable by any node in the pool). Rows pinned to a
93/// *different* node `Some(M)` where `M != N` are excluded.
94///
95/// This matches the NODE-AFFINITY model where `node` on a row is OPTIONAL locality
96/// ([`OutboxRow::node`]): unpinned rows (`None`) are the genuine current behaviour — claimable by
97/// anyone in the pool — so a node-scoped claim must keep serving them, otherwise enabling affinity
98/// for some rows would silently strand every unpinned row. A `node: None` scope (a pool that
99/// advertises no locality) claims only unpinned rows, never another node's pinned rows.
100#[derive(Clone, Debug, PartialEq, Eq)]
101pub struct ClaimScope {
102    /// Namespace the pool serves; only rows with this exact `namespace` are in scope.
103    pub namespace: String,
104    /// Task queue the pool serves; only rows with this exact `task_queue` are in scope.
105    pub task_queue: String,
106    /// Claiming node's locality id, or `None` for a pool that advertises no node affinity.
107    ///
108    /// `Some(n)` claims rows with `node == Some(n)` AND unpinned rows (`node == None`). `None` claims
109    /// only unpinned rows (`node == None`).
110    pub node: Option<String>,
111}
112
113impl ClaimScope {
114    /// Builds a scope for the `(namespace, task_queue)` pool with no node locality.
115    #[must_use]
116    pub fn new(namespace: impl Into<String>, task_queue: impl Into<String>) -> Self {
117        Self {
118            namespace: namespace.into(),
119            task_queue: task_queue.into(),
120            node: None,
121        }
122    }
123
124    /// Sets the claiming node's locality id on this scope.
125    #[must_use]
126    pub fn with_node(mut self, node: Option<String>) -> Self {
127        self.node = node;
128        self
129    }
130
131    /// Returns whether `row` is servable under this scope.
132    ///
133    /// True iff the namespace and task queue match exactly AND the node predicate holds: the row is
134    /// unpinned (`node == None`) or pinned to this scope's node (`row.node == self.node` when
135    /// `self.node` is `Some`). See the [type docs](ClaimScope#node-predicate) for the rationale.
136    #[must_use]
137    pub fn admits(&self, row: &OutboxRow) -> bool {
138        row.namespace == self.namespace
139            && row.task_queue == self.task_queue
140            && match (&self.node, &row.node) {
141                // Unpinned rows are servable by any node in the pool.
142                (_, None) => true,
143                // A pinned row is servable only by the node it is pinned to.
144                (Some(scope_node), Some(row_node)) => scope_node == row_node,
145                // A pool with no locality cannot serve another node's pinned row.
146                (None, Some(_)) => false,
147            }
148    }
149}
150
151/// One durable fan-out dispatch staged for a worker.
152///
153/// The row carries everything the out-of-band dispatcher needs to send the activity without
154/// reading workflow history: the originating workflow, the pinned `ordinal` within its fan-out
155/// range, the derived `dispatch_key` idempotency guard, the activity type, and the input payload.
156/// `attempt`, `visible_after`, `claimed_at`, and `status` track retry/backoff and claim state.
157/// `claimed_at` is set only while a row is [`OutboxStatus::Claimed`]; pending and terminal rows
158/// keep it `None` so stale-claim reconciliation only considers durable claimed rows.
159#[derive(Clone, Debug, PartialEq, Eq)]
160pub struct OutboxRow {
161    /// Database-level idempotency key, canonically `"{workflow_id}:{ordinal}"`.
162    pub dispatch_key: String,
163    /// Workflow that scheduled this fan-out activity.
164    pub workflow_id: WorkflowId,
165    /// Pinned ordinal of this activity within the workflow's fan-out range.
166    pub ordinal: u64,
167    /// Run that dispatched this ordinal; `None` for legacy rows (pre-RunId threading). Threaded so a
168    /// completion only resolves the run that issued it (continue-as-new safety, OBX-011).
169    pub run_id: Option<RunId>,
170    /// Workflow's durable isolation namespace — the correctness boundary the dispatched activity must
171    /// route within. Legacy rows (pre-NSTQ-2, persisted before the column existed) read back as the
172    /// `"default"` namespace. Carried on the row so the dispatcher routes via the workflow's real
173    /// namespace instead of inventing the server default (NSTQ-2).
174    pub namespace: String,
175    /// Pool/flavour selector within the namespace. There is no SDK-level task-queue selection yet
176    /// (NSTQ-4), so a freshly staged row carries the named `"default"` task queue; legacy rows
177    /// (pre-NSTQ-2) also read back as `"default"`. Carried on the row so the dispatcher routes via the
178    /// row's real selector (NSTQ-2).
179    pub task_queue: String,
180    /// OPTIONAL locality affinity within the `(namespace, task_queue)` pool. `None` = no affinity =
181    /// any worker in the pool (the genuine current behaviour: there is no SDK-level node selection
182    /// yet — NODE-4). `Some(node)` pins the dispatch to workers advertising that node id. Legacy
183    /// rows (pre-NODE-2, persisted before the column existed) read back as `None`: a NULL column is
184    /// "no affinity", NOT a sentinel string (NODE-2).
185    pub node: Option<String>,
186    /// Activity type the worker must execute.
187    pub activity_type: String,
188    /// Opaque activity input payload.
189    pub input: Payload,
190    /// Lifecycle state of this row.
191    pub status: OutboxStatus,
192    /// Zero-based dispatch attempt count; incremented on each retry.
193    pub attempt: u32,
194    /// Earliest instant at which this row becomes claimable (retry backoff fence).
195    pub visible_after: DateTime<Utc>,
196    /// Durable instant at which the row was claimed; absent unless `status` is `Claimed`.
197    pub claimed_at: Option<DateTime<Utc>>,
198}
199
200impl OutboxRow {
201    /// Builds the canonical `dispatch_key` for a `(workflow_id, ordinal)` pair.
202    ///
203    /// This is the single source of truth for the idempotency key format so the append path and any
204    /// completion-routing lookups agree byte-for-byte.
205    #[must_use]
206    pub fn dispatch_key_for(workflow_id: &WorkflowId, ordinal: u64) -> String {
207        format!("{workflow_id}:{ordinal}")
208    }
209
210    /// Constructs a fresh `Pending` row for `(workflow_id, ordinal)` with attempt zero.
211    ///
212    /// `visible_after` is set to `now` so the row is immediately claimable. The `dispatch_key` is
213    /// derived via [`OutboxRow::dispatch_key_for`].
214    #[must_use]
215    pub fn pending(
216        workflow_id: WorkflowId,
217        ordinal: u64,
218        activity_type: String,
219        input: Payload,
220        now: DateTime<Utc>,
221    ) -> Self {
222        let dispatch_key = Self::dispatch_key_for(&workflow_id, ordinal);
223        Self {
224            dispatch_key,
225            workflow_id,
226            ordinal,
227            run_id: None,
228            namespace: String::from(DEFAULT_OUTBOX_ROUTE),
229            task_queue: String::from(DEFAULT_OUTBOX_ROUTE),
230            node: None,
231            activity_type,
232            input,
233            status: OutboxStatus::Pending,
234            attempt: 0,
235            visible_after: now,
236            claimed_at: None,
237        }
238    }
239
240    /// Sets the dispatching run on this row (the run that owns this ordinal).
241    #[must_use]
242    pub fn with_run_id(mut self, run_id: Option<RunId>) -> Self {
243        self.run_id = run_id;
244        self
245    }
246
247    /// Sets the workflow's durable isolation namespace on this row (the routing correctness boundary).
248    #[must_use]
249    pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
250        self.namespace = namespace.into();
251        self
252    }
253
254    /// Sets the pool/flavour selector (task queue) on this row.
255    #[must_use]
256    pub fn with_task_queue(mut self, task_queue: impl Into<String>) -> Self {
257        self.task_queue = task_queue.into();
258        self
259    }
260
261    /// Sets the OPTIONAL node affinity on this row. `None` = no affinity (any worker in the pool).
262    #[must_use]
263    pub fn with_node(mut self, node: Option<String>) -> Self {
264        self.node = node;
265        self
266    }
267}
268
269/// Durable staging and claim contract for store-backed fan-out dispatch.
270///
271/// Implementations append outbox rows transactionally with workflow-history events, hand pending
272/// rows to a single dispatcher under the single-writer model, and record terminal outcomes. All
273/// methods are idempotency-aware: appending a duplicate `dispatch_key` is silently ignored, and the
274/// completion/retry/fail transitions key off `dispatch_key`.
275#[async_trait]
276pub trait OutboxStore: Send + Sync + 'static {
277    /// Inserts `rows` into the outbox, silently ignoring any whose `dispatch_key` already exists.
278    ///
279    /// This is the standalone (non-atomic-with-events) append used for tests and out-of-band
280    /// staging. The atomic-with-history append lives on the concrete store as `append_with_outbox`.
281    /// Duplicate keys are ignored via `INSERT OR IGNORE`, preserving at-most-once dispatch.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`StoreError::Backend`] for backend boundary failures and
286    /// [`StoreError::Serialization`] when a row cannot be encoded.
287    async fn append_outbox_batch(&self, rows: &[OutboxRow]) -> Result<(), StoreError>;
288
289    /// Atomically claims up to `limit` pending rows whose `visible_after` has passed.
290    ///
291    /// Claimed rows are transitioned to [`OutboxStatus::Claimed`] and returned. Under the
292    /// single-writer IMMEDIATE model this is the SQLite-equivalent of `SELECT ... FOR UPDATE SKIP
293    /// LOCKED`: no two dispatchers observe the same pending row as claimable.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`StoreError::Backend`] for backend boundary failures and
298    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
299    async fn claim_outbox_rows(&self, limit: u32) -> Result<Vec<OutboxRow>, StoreError>;
300
301    /// Atomically claims up to `limit` due pending rows whose owning workflow is
302    /// NOT in `held` (the pause dispatch-hold, #204).
303    ///
304    /// This is the pause-aware counterpart to [`OutboxStore::claim_outbox_rows`]:
305    /// a row whose `workflow_id` is in `held` is never selected, so it stays
306    /// [`OutboxStatus::Pending`] for the entire paused window — it is NEVER
307    /// transitioned to [`OutboxStatus::Claimed`] and released, so no un-claim
308    /// primitive is needed. Release is purely resume (the workflow leaves `held`)
309    /// plus the ordinary interval/wake sweep. With an empty `held` set this is
310    /// byte-identical to [`OutboxStore::claim_outbox_rows`].
311    ///
312    /// The default implementation ignores `held` and delegates to
313    /// [`OutboxStore::claim_outbox_rows`]: a test-double store that never pauses
314    /// is unaffected. The bundled durable backends (libSQL, haematite) override
315    /// it to apply the exclusion inside the same atomic, single-writer claim so a
316    /// held row is never claimed even under concurrent sweeps.
317    ///
318    /// # Errors
319    ///
320    /// Returns [`StoreError::Backend`] for backend boundary failures and
321    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
322    async fn claim_outbox_rows_excluding(
323        &self,
324        limit: u32,
325        held: &std::collections::HashSet<WorkflowId>,
326    ) -> Result<Vec<OutboxRow>, StoreError> {
327        let _ = held;
328        self.claim_outbox_rows(limit).await
329    }
330
331    /// Atomically claims up to `limit` pending rows that are due AND in `scope` (LSUB-1a).
332    ///
333    /// This is the node-affinity-aware counterpart to [`OutboxStore::claim_outbox_rows`]: it adds a
334    /// `(namespace, task_queue, node)` predicate to the same atomic, single-writer claim and is
335    /// otherwise byte-identical (same due/order/limit/claim semantics). The unscoped method is left
336    /// exactly as it was — passing no scope is still "claim any visible row" — so the existing
337    /// single-server poll loop is unaffected.
338    ///
339    /// A row is in scope when its `namespace` and `task_queue` match `scope` exactly and the node
340    /// predicate holds: the row is unpinned (`node == None`, servable by any node in the pool) or
341    /// pinned to `scope.node`. See [`ClaimScope`] for the full node-predicate rationale.
342    ///
343    /// # Errors
344    ///
345    /// Returns [`StoreError::Backend`] for backend boundary failures and
346    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
347    async fn claim_outbox_rows_scoped(
348        &self,
349        scope: &ClaimScope,
350        limit: u32,
351    ) -> Result<Vec<OutboxRow>, StoreError>;
352
353    /// Atomically claims up to `limit` due pending rows that are in `scope` AND whose
354    /// owning workflow is NOT in `held` (the pause dispatch-hold, #204).
355    ///
356    /// This is the pause-aware counterpart to [`OutboxStore::claim_outbox_rows_scoped`]:
357    /// it is the scoped (backpressure / node-affinity) claim path with the same
358    /// held-exclusion as [`OutboxStore::claim_outbox_rows_excluding`]. The production
359    /// outbox dispatcher runs under keyed backpressure and therefore claims through the
360    /// SCOPED path, so the pause hold MUST be applied here too — otherwise a held row
361    /// would still be claimed and dispatched under backpressure. A held row is never
362    /// flipped to [`OutboxStatus::Claimed`]; it stays [`OutboxStatus::Pending`] for the
363    /// whole paused window and release is purely resume plus the ordinary sweep.
364    ///
365    /// The default implementation ignores `held` and delegates to
366    /// [`OutboxStore::claim_outbox_rows_scoped`]: a test-double store that never pauses
367    /// is unaffected. The bundled durable backends (libSQL, haematite) override it to
368    /// apply the exclusion inside the same atomic, single-writer claim.
369    ///
370    /// # Errors
371    ///
372    /// Returns [`StoreError::Backend`] for backend boundary failures and
373    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
374    async fn claim_outbox_rows_scoped_excluding(
375        &self,
376        scope: &ClaimScope,
377        limit: u32,
378        held: &std::collections::HashSet<WorkflowId>,
379    ) -> Result<Vec<OutboxRow>, StoreError> {
380        let _ = held;
381        self.claim_outbox_rows_scoped(scope, limit).await
382    }
383
384    /// Returns up to `limit` STALE claimed rows — `status` is [`OutboxStatus::Claimed`] and the
385    /// durable `claimed_at` is older than `older_than` — WITHOUT transitioning them (#253).
386    ///
387    /// This is the read-only probe half of stale-claim reconciliation: it selects exactly the rows
388    /// [`OutboxStore::rearm_stale_claimed_outbox_rows`] would re-arm (same status/`claimed_at`
389    /// predicate, same `claimed_at ASC, dispatch_key ASC` order, same `NULL claimed_at` exclusion)
390    /// so the reconciler can project each candidate workflow's liveness FIRST and settle rows whose
391    /// workflow is already terminal instead of re-arming a dispatch nobody may deliver (the
392    /// incident's zombie-round hole). Claims and mutates nothing.
393    ///
394    /// There is deliberately no silently-empty default: a store that cannot enumerate stale claims
395    /// cannot host the liveness-gated reconciler, and pretending "no stale rows" would re-open the
396    /// ungated re-arm. Outbox-bearing backends must implement it.
397    ///
398    /// # Errors
399    ///
400    /// Returns [`StoreError::Backend`] for backend boundary failures (including a store that has not
401    /// implemented this probe) and [`StoreError::Serialization`] when a stored row cannot be decoded.
402    async fn list_stale_claimed_outbox_rows(
403        &self,
404        older_than: DateTime<Utc>,
405        limit: u32,
406    ) -> Result<Vec<OutboxRow>, StoreError> {
407        let _ = (older_than, limit);
408        Err(StoreError::Backend(String::from(
409            "this outbox store does not support the stale-claim liveness probe; \
410             refusing to report an empty stale set (override OutboxStore::list_stale_claimed_outbox_rows)",
411        )))
412    }
413
414    /// Returns the distinct workflow ids owning at least one UNSETTLED row — `status` is
415    /// [`OutboxStatus::Pending`] or [`OutboxStatus::Claimed`] — scoped to this node's owned shards
416    /// like every other outbox enumeration (#253).
417    ///
418    /// This is the boot/adoption sweep's enumeration primitive: after a restart (or a shard
419    /// adoption) the server projects each returned workflow's status once and settles the rows of
420    /// terminal workflows via [`OutboxStore::cancel_outbox_rows_for_workflow`], closing the window
421    /// where a workflow reached its terminal without its rows being settled (a settle-hook failure,
422    /// or a terminal recorded by a node that died before settling). Bounded by the number of
423    /// workflows with live rows; read-only.
424    ///
425    /// No silently-empty default, for the same reason as
426    /// [`OutboxStore::list_stale_claimed_outbox_rows`]: an empty answer from a store that never
427    /// looked would silently disable the boot repair.
428    ///
429    /// # Errors
430    ///
431    /// Returns [`StoreError::Backend`] for backend boundary failures (including a store that has not
432    /// implemented this enumeration) and [`StoreError::Serialization`] when a stored row cannot be
433    /// decoded.
434    async fn list_unsettled_outbox_workflow_ids(&self) -> Result<Vec<WorkflowId>, StoreError> {
435        Err(StoreError::Backend(String::from(
436            "this outbox store does not support unsettled-workflow enumeration; \
437             refusing to report an empty set (override OutboxStore::list_unsettled_outbox_workflow_ids)",
438        )))
439    }
440
441    /// Idempotently settles EVERY live ([`OutboxStatus::Pending`] or [`OutboxStatus::Claimed`]) row
442    /// of `workflow_id` to [`OutboxStatus::Cancelled`], returning the settled `dispatch_key`s
443    /// (#253).
444    ///
445    /// The [`OutboxStore`]-facing twin of
446    /// [`crate::WritableEventStore::settle_workflow_outbox_rows_cancelled`] (concrete stores share
447    /// one implementation), exposed here so the server-side boot sweep and the stale-claim
448    /// reconciler — which hold an outbox-store handle, not a writer — can settle a terminal
449    /// workflow's rows. Terminal rows (`Done`/`Failed`/`Cancelled`) are never touched, and
450    /// [`crate::WritableEventStore::rearm_outbox_pending`] still supersedes the settle on reopen.
451    ///
452    /// No silently-succeeding default: a store that cannot settle must refuse loudly rather than
453    /// leave a terminal workflow's rows claimable.
454    ///
455    /// # Errors
456    ///
457    /// Returns [`StoreError::Backend`] for backend boundary failures (including a store that has not
458    /// implemented the settle) and [`StoreError::Serialization`] when a stored row cannot be
459    /// decoded.
460    async fn cancel_outbox_rows_for_workflow(
461        &self,
462        workflow_id: &WorkflowId,
463    ) -> Result<Vec<String>, StoreError> {
464        let _ = workflow_id;
465        Err(StoreError::Backend(String::from(
466            "this outbox store does not support workflow-terminal settlement; \
467             refusing to no-op a settle (override OutboxStore::cancel_outbox_rows_for_workflow)",
468        )))
469    }
470
471    /// Re-arms stale claimed rows so a live dispatcher can claim them again without restart.
472    ///
473    /// Implementations atomically select up to `limit` rows whose `status` is
474    /// [`OutboxStatus::Claimed`] and whose durable `claimed_at` timestamp is older than
475    /// `older_than`, then transition only those rows back to [`OutboxStatus::Pending`] with
476    /// `visible_after` set to the supplied instant. The existing `attempt` value is preserved and
477    /// `claimed_at` is cleared. Rows in `Done` or `Failed` are terminal and must never be touched.
478    /// Rows in `Cancelled` are also terminal and must never be touched.
479    ///
480    /// Claimed rows without a durable `claimed_at` value are deliberately ignored: the caller asked
481    /// for rows older than a supplied instant, and `NULL` cannot satisfy that predicate safely.
482    ///
483    /// # Errors
484    ///
485    /// Returns [`StoreError::Backend`] for backend boundary failures and
486    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
487    async fn rearm_stale_claimed_outbox_rows(
488        &self,
489        older_than: DateTime<Utc>,
490        visible_after: DateTime<Utc>,
491        limit: u32,
492    ) -> Result<Vec<OutboxRow>, StoreError>;
493
494    /// Marks the row identified by `dispatch_key` as [`OutboxStatus::Done`].
495    ///
496    /// A `dispatch_key` with no matching row is a no-op (the dedup guard may have removed it), not
497    /// an error.
498    ///
499    /// # Errors
500    ///
501    /// Returns [`StoreError::Backend`] for backend boundary failures.
502    async fn complete_outbox_row(&self, dispatch_key: &str) -> Result<(), StoreError>;
503
504    /// Returns the row identified by `dispatch_key` to [`OutboxStatus::Pending`] for retry.
505    ///
506    /// Sets `attempt` to `next_attempt` and `visible_after` to `visible_after` so the dispatcher
507    /// honours backoff before re-claiming. An absent `dispatch_key` is a no-op.
508    ///
509    /// # Errors
510    ///
511    /// Returns [`StoreError::Backend`] for backend boundary failures.
512    async fn retry_outbox_row(
513        &self,
514        dispatch_key: &str,
515        next_attempt: u32,
516        visible_after: DateTime<Utc>,
517    ) -> Result<(), StoreError>;
518
519    /// Marks the row identified by `dispatch_key` as [`OutboxStatus::Failed`] (dead letter).
520    ///
521    /// An absent `dispatch_key` is a no-op.
522    ///
523    /// # Errors
524    ///
525    /// Returns [`StoreError::Backend`] for backend boundary failures.
526    async fn fail_outbox_row(&self, dispatch_key: &str) -> Result<(), StoreError>;
527
528    /// Returns the count of in-flight outbox rows for `namespace` (CP2-Q1.5).
529    ///
530    /// "In-flight" is the dispatched-but-not-terminal set: rows whose `status` is
531    /// [`OutboxStatus::Pending`] OR [`OutboxStatus::Claimed`]. Terminal rows
532    /// ([`OutboxStatus::Done`], [`OutboxStatus::Failed`], [`OutboxStatus::Cancelled`]) are excluded.
533    ///
534    /// This is the durable, restart-correct quota source that replaces the in-memory
535    /// `inflight_activities` gauge proven dead in P2-Q0 (see `docs/design/CONTROL-PLANE-PHASE-2.md`
536    /// §3.3/§8). Because it counts durable rows, the count survives a restart, and because a
537    /// `Claimed` row is in-flight, a row that dispatched but whose `mark_done` failed (the
538    /// stuck-`Claimed` case) is still counted — it has not reached a terminal outcome and the worker
539    /// may still be running it. The count is strictly scoped to `namespace`: rows in any other
540    /// namespace are never included.
541    ///
542    /// Nothing consumes this yet (P2-Q2 will); it is a pure additive store query with no behaviour
543    /// change.
544    ///
545    /// # Errors
546    ///
547    /// Returns [`StoreError::Backend`] for backend boundary failures and
548    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
549    async fn count_inflight_outbox_rows(&self, namespace: &str) -> Result<u64, StoreError>;
550
551    /// Returns the count of CLAIMED outbox rows for `namespace` (CP2-Q2).
552    ///
553    /// "Claimed" is the *concurrently executing* set: rows in [`OutboxStatus::Claimed`] — dispatched
554    /// to a worker and not yet terminal. This is deliberately NARROWER than
555    /// [`OutboxStore::count_inflight_outbox_rows`], which also counts [`OutboxStatus::Pending`]
556    /// backlog: a tenant sitting on a large Pending backlog has a large *in-flight* count but a small
557    /// *claimed* count, and it is the CLAIMED count — concurrent executing activities — that the
558    /// keyed-backpressure ceiling caps (CP-Phase-2 §3.1 as corrected). Counting Pending+Claimed for
559    /// headroom would wedge a tenant against its own backlog: it could never claim the Pending rows
560    /// that make up the count. So headroom is `per_node_ceiling − claimed`, never `… − inflight`.
561    ///
562    /// A stuck-`Claimed` row (dispatched but `mark_done` never landed, `outbox_dispatcher` §) is
563    /// still `Claimed` and so still counts — the worker may still be executing it, so it correctly
564    /// occupies a concurrency slot. The count is strictly scoped to `namespace`: rows in any other
565    /// namespace are never included.
566    ///
567    /// # Errors
568    ///
569    /// Returns [`StoreError::Backend`] for backend boundary failures and
570    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
571    async fn count_claimed_outbox_rows(&self, namespace: &str) -> Result<u64, StoreError>;
572
573    /// Counts the CLAIMED outbox rows for each namespace in `namespaces`, in ONE pass (CP2-Q2 perf).
574    ///
575    /// Same semantics as calling [`OutboxStore::count_claimed_outbox_rows`] once per namespace — the
576    /// CLAIMED-only ([`OutboxStatus::Claimed`]), owned-shard-scoped concurrent-executing count that
577    /// feeds the keyed-backpressure headroom — but collapsed into a single scan of the owned-shard
578    /// set instead of N repeated scans over the same rows (the N+1 the per-sweep planner would
579    /// otherwise incur, one full scan per active namespace). The returned map has EXACTLY one entry
580    /// per requested namespace: a namespace with no claimed rows maps to `0`, so the caller can index
581    /// it unconditionally. Namespaces not in `namespaces` are never counted (nor returned).
582    ///
583    /// The default implementation preserves the contract by delegating to the per-namespace method
584    /// (an honest, correct fallback for any store that has not specialised the single-scan form); the
585    /// bundled stores override it with a genuine one-pass scan / grouped query.
586    ///
587    /// # Errors
588    ///
589    /// Returns [`StoreError::Backend`] for backend boundary failures and
590    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
591    async fn count_claimed_outbox_rows_by_namespace(
592        &self,
593        namespaces: &[&str],
594    ) -> Result<std::collections::BTreeMap<String, u64>, StoreError> {
595        let mut counts = std::collections::BTreeMap::new();
596        for namespace in namespaces {
597            let count = self.count_claimed_outbox_rows(namespace).await?;
598            counts.insert((*namespace).to_owned(), count);
599        }
600        Ok(counts)
601    }
602
603    /// Enumerates the distinct `(namespace, task_queue, node)` routes that currently have at least
604    /// one CLAIMABLE pending row — a row whose `status` is [`OutboxStatus::Pending`] and whose
605    /// `visible_after` fence has passed (CP2-Q2).
606    ///
607    /// This is the enumeration primitive the keyed-backpressure dispatcher round-robins over: it
608    /// cannot ask [`OutboxStore::claim_outbox_rows_scoped`] (which needs a *specific*
609    /// [`ClaimScope`]) to "claim across all namespaces", so it first probes which routes have work
610    /// and then issues one scoped, headroom-capped claim per route. Each returned [`ClaimScope`]
611    /// carries the exact `(namespace, task_queue, node)` of pending rows, so a subsequent
612    /// `claim_outbox_rows_scoped` with that scope claims those rows (and any unpinned rows in the
613    /// same pool — see [`ClaimScope`]). A route with only future-fenced (`visible_after > now`) or
614    /// terminal rows is NOT returned: there is nothing claimable to dispatch.
615    ///
616    /// The probe is read-only and claims nothing; it only shapes which scopes the dispatcher then
617    /// claims under. On a node that owns a shard subset, only routes with claimable rows on owned
618    /// shards are returned (the same owned-shard scoping as the claim path), so the per-node round
619    /// naturally sees only its proportional slice of each tenant's work.
620    ///
621    /// # Errors
622    ///
623    /// Returns [`StoreError::Backend`] for backend boundary failures and
624    /// [`StoreError::Serialization`] when a stored row cannot be decoded.
625    async fn pending_outbox_routes(&self) -> Result<Vec<ClaimScope>, StoreError>;
626}
627
628#[cfg(test)]
629mod tests {
630    use std::sync::Arc;
631
632    use aion_core::{ContentType, Payload, WorkflowId};
633    use chrono::Utc;
634
635    use super::{ClaimScope, OutboxRow, OutboxStatus, OutboxStore};
636
637    #[test]
638    fn outbox_store_is_object_safe() {
639        let _: Option<Arc<dyn OutboxStore>> = None;
640    }
641
642    fn row(namespace: &str, task_queue: &str, node: Option<&str>) -> OutboxRow {
643        OutboxRow::pending(
644            WorkflowId::new_v4(),
645            0,
646            String::from("charge"),
647            Payload::new(ContentType::Json, b"{}".to_vec()),
648            Utc::now(),
649        )
650        .with_namespace(namespace)
651        .with_task_queue(task_queue)
652        .with_node(node.map(ToOwned::to_owned))
653    }
654
655    #[test]
656    fn scope_admits_matching_namespace_task_queue_and_unpinned_or_matching_node() {
657        let scope = ClaimScope::new("remote", "gpu").with_node(Some("box-7".to_owned()));
658        // Pinned to the scope's node: admitted.
659        assert!(scope.admits(&row("remote", "gpu", Some("box-7"))));
660        // Unpinned (no affinity): admitted by any node in the pool.
661        assert!(scope.admits(&row("remote", "gpu", None)));
662    }
663
664    #[test]
665    fn scope_rejects_other_namespace_task_queue_or_pinned_to_other_node() {
666        let scope = ClaimScope::new("remote", "gpu").with_node(Some("box-7".to_owned()));
667        // Wrong namespace.
668        assert!(!scope.admits(&row("default", "gpu", None)));
669        // Wrong task queue.
670        assert!(!scope.admits(&row("remote", "cpu", None)));
671        // Pinned to a different node.
672        assert!(!scope.admits(&row("remote", "gpu", Some("box-9"))));
673    }
674
675    #[test]
676    fn node_less_scope_admits_only_unpinned_rows() {
677        let scope = ClaimScope::new("remote", "gpu");
678        assert!(scope.admits(&row("remote", "gpu", None)));
679        // A node-less pool cannot serve a row pinned to a specific node.
680        assert!(!scope.admits(&row("remote", "gpu", Some("box-7"))));
681    }
682
683    #[test]
684    fn status_tokens_round_trip() -> Result<(), crate::StoreError> {
685        for status in [
686            OutboxStatus::Pending,
687            OutboxStatus::Claimed,
688            OutboxStatus::Done,
689            OutboxStatus::Failed,
690            OutboxStatus::Cancelled,
691        ] {
692            let parsed = OutboxStatus::parse_token(status.as_str())?;
693            assert_eq!(parsed, status);
694        }
695        Ok(())
696    }
697
698    #[test]
699    fn unknown_status_token_is_rejected() {
700        assert!(OutboxStatus::parse_token("nope").is_err());
701    }
702
703    #[test]
704    fn dispatch_key_is_workflow_id_colon_ordinal() {
705        let workflow_id = aion_core::WorkflowId::new_v4();
706        let key = OutboxRow::dispatch_key_for(&workflow_id, 7);
707        assert_eq!(key, format!("{workflow_id}:7"));
708    }
709}