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}