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}