Skip to main content

aion_store_libsql/
outbox.rs

1//! libSQL-backed durable outbox: staging, claim, and terminal transitions.
2//!
3//! Rows are inserted with `INSERT OR IGNORE` so a duplicate `dispatch_key` is silently dropped,
4//! preserving at-most-once dispatch. Claims run under an `IMMEDIATE` transaction (the single-writer
5//! `SQLite` equivalent of `SELECT ... FOR UPDATE SKIP LOCKED`): pending rows are flipped to `claimed`
6//! and returned in one atomic step so no two dispatchers observe the same row as claimable.
7
8use aion_store::{
9    ClaimScope, DEFAULT_OUTBOX_ROUTE, OutboxRow, OutboxStatus, Payload, RunId, StoreError,
10    WorkflowId,
11};
12use chrono::{DateTime, SecondsFormat, Utc};
13use libsql::{Connection, Row, Transaction, TransactionBehavior, params};
14
15mod transitions;
16pub(crate) use transitions::{
17    complete_outbox_row, fail_outbox_row, retry_outbox_row, settle_outbox_row_cancelled,
18};
19
20const INSERT_OUTBOX_SQL: &str = "
21INSERT OR IGNORE INTO outbox
22    (dispatch_key, workflow_id, ordinal, activity_type, input, status, attempt, visible_after, claimed_at, run_id, namespace, task_queue, node)
23VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)";
24
25const REARM_OUTBOX_SQL: &str = "
26INSERT INTO outbox
27    (dispatch_key, workflow_id, ordinal, activity_type, input, status, attempt, visible_after, claimed_at, namespace, task_queue, node)
28VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11)
29ON CONFLICT(dispatch_key) DO UPDATE SET status = 'pending', visible_after = ?8, claimed_at = NULL";
30
31const SELECT_CLAIMABLE_SQL: &str = "
32SELECT dispatch_key, workflow_id, ordinal, activity_type, input, status, attempt, visible_after, claimed_at, run_id, namespace, task_queue, node
33FROM outbox
34WHERE status = 'pending' AND visible_after <= ?1
35ORDER BY visible_after ASC, dispatch_key ASC
36LIMIT ?2";
37
38// Unbounded variant for the pause dispatch-hold (#204): the held-workflow filter runs
39// in Rust AFTER the SELECT, so a SQL LIMIT would let a paused workflow's due rows
40// (which sort earliest) permanently occupy the whole window and starve every other
41// workflow on the route. The excluding claims therefore stream WITHOUT a LIMIT and
42// stop once `limit` claimable rows are taken — held rows cost one decode each, never
43// a claim slot.
44const SELECT_CLAIMABLE_UNBOUNDED_SQL: &str = "
45SELECT dispatch_key, workflow_id, ordinal, activity_type, input, status, attempt, visible_after, claimed_at, run_id, namespace, task_queue, node
46FROM outbox
47WHERE status = 'pending' AND visible_after <= ?1
48ORDER BY visible_after ASC, dispatch_key ASC";
49
50// Scoped claim (LSUB-1a): additionally constrain by the pool's `(namespace, task_queue)` and the
51// node predicate. The node clause is appended per-scope: a node-bearing scope claims rows pinned to
52// that node OR unpinned rows (`node IS NULL`); a node-less scope claims only unpinned rows. This is
53// the byte-identical due/order/limit claim with an extra WHERE conjunction.
54const SELECT_CLAIMABLE_SCOPED_PREFIX_SQL: &str = "
55SELECT dispatch_key, workflow_id, ordinal, activity_type, input, status, attempt, visible_after, claimed_at, run_id, namespace, task_queue, node
56FROM outbox
57WHERE status = 'pending' AND visible_after <= ?1 AND namespace = ?3 AND task_queue = ?4";
58
59const SELECT_CLAIMABLE_SCOPED_SUFFIX_SQL: &str = "
60ORDER BY visible_after ASC, dispatch_key ASC
61LIMIT ?2";
62
63// Scoped unbounded suffix: same starvation rationale as
64// [`SELECT_CLAIMABLE_UNBOUNDED_SQL`] — used only when a non-empty held set makes the
65// Rust-side filter load-bearing. `?2` is unused here; the scoped bindings renumber.
66const SELECT_CLAIMABLE_SCOPED_SUFFIX_UNBOUNDED_SQL: &str = "
67ORDER BY visible_after ASC, dispatch_key ASC";
68
69// Node predicate fragments spliced between the prefix and suffix above. `?5` binds the scope node.
70const NODE_CLAUSE_PINNED_OR_UNPINNED: &str = " AND (node IS NULL OR node = ?5)";
71const NODE_CLAUSE_UNPINNED_ONLY: &str = " AND node IS NULL";
72
73const CLAIM_ROW_SQL: &str = "
74UPDATE outbox SET status = 'claimed', claimed_at = ?2 WHERE dispatch_key = ?1 AND status = 'pending'";
75
76// In-flight count (CP2-Q1.5): rows in this namespace that are dispatched-but-not-terminal, i.e.
77// `pending` OR `claimed`. Terminal rows (`done`, `failed`, `cancelled`) are excluded by the IN list,
78// and the predicate is strictly scoped by `namespace = ?1` so no other namespace bleeds in.
79const COUNT_INFLIGHT_OUTBOX_SQL: &str = "
80SELECT COUNT(*) FROM outbox WHERE namespace = ?1 AND status IN ('pending', 'claimed')";
81
82// Claimed-only count (CP2-Q2): rows in this namespace that are concurrently EXECUTING, i.e. `claimed`
83// only — NOT the pending backlog. This is the keyed-backpressure headroom input (a tenant must not
84// wedge itself against its own Pending backlog), strictly narrower than the in-flight count above.
85const COUNT_CLAIMED_OUTBOX_SQL: &str = "
86SELECT COUNT(*) FROM outbox WHERE namespace = ?1 AND status = 'claimed'";
87
88// Claimed-only count grouped by namespace (CP2-Q2 perf): one grouped query so the per-sweep planner
89// resolves every active namespace's claimed count in a single round-trip instead of N repeated
90// per-namespace COUNTs over the same table. Same `status = 'claimed'` predicate as the scalar count
91// above; the caller filters to the requested namespace set and seeds absent ones to zero.
92const COUNT_CLAIMED_OUTBOX_BY_NAMESPACE_SQL: &str = "
93SELECT namespace, COUNT(*) FROM outbox WHERE status = 'claimed' GROUP BY namespace";
94
95// Pending-route enumeration (CP2-Q2): the distinct `(namespace, task_queue, node)` routes that have
96// at least one CLAIMABLE pending row (status pending AND the visible_after fence has passed). This is
97// the round-robin probe — it claims nothing, it only reports which scopes have work. `node` is
98// SELECTed verbatim (NULL stays NULL = unpinned) so the dispatcher rebuilds the exact ClaimScope.
99const PENDING_OUTBOX_ROUTES_SQL: &str = "
100SELECT DISTINCT namespace, task_queue, node
101FROM outbox
102WHERE status = 'pending' AND visible_after <= ?1";
103
104const SELECT_STALE_CLAIMED_SQL: &str = "
105SELECT dispatch_key, workflow_id, ordinal, activity_type, input, status, attempt, visible_after, claimed_at, run_id, namespace, task_queue, node
106FROM outbox
107WHERE status = 'claimed' AND claimed_at IS NOT NULL AND claimed_at < ?1
108ORDER BY claimed_at ASC, dispatch_key ASC
109LIMIT ?2";
110
111// Workflow-terminal settle (#253): the live (pending|claimed) keys of one workflow, selected
112// inside the same IMMEDIATE transaction that flips them to 'cancelled', so the returned keys are
113// exactly the rows this settle retired. Terminal rows (done/failed/cancelled) are never touched.
114const SELECT_LIVE_KEYS_FOR_WORKFLOW_SQL: &str = "
115SELECT dispatch_key FROM outbox
116WHERE workflow_id = ?1 AND status IN ('pending', 'claimed')
117ORDER BY dispatch_key ASC";
118
119const SETTLE_WORKFLOW_LIVE_ROWS_SQL: &str = "
120UPDATE outbox SET status = 'cancelled', claimed_at = NULL
121WHERE workflow_id = ?1 AND status IN ('pending', 'claimed')";
122
123// Unsettled-workflow enumeration (#253): the distinct owners of any live (pending|claimed) row —
124// the boot/adoption sweep's candidate set for terminal-workflow settlement. Read-only.
125const SELECT_UNSETTLED_WORKFLOW_IDS_SQL: &str = "
126SELECT DISTINCT workflow_id FROM outbox
127WHERE status IN ('pending', 'claimed')
128ORDER BY workflow_id ASC";
129
130const REARM_STALE_CLAIMED_ROW_SQL: &str = "
131UPDATE outbox SET status = 'pending', visible_after = ?2, claimed_at = NULL
132WHERE dispatch_key = ?1 AND status = 'claimed'";
133
134/// Insert a single outbox row inside an existing transaction.
135///
136/// Shared by the standalone [`append_outbox_batch`] and the atomic-with-history `append_with_outbox`
137/// path so both honour the `INSERT OR IGNORE` idempotency guard identically.
138///
139/// # Errors
140///
141/// Returns `StoreError::Serialization` when the input payload cannot be encoded and
142/// `StoreError::Backend` for libSQL boundary failures.
143pub(crate) async fn insert_outbox_row(tx: &Transaction, row: &OutboxRow) -> Result<(), StoreError> {
144    let input = encode_payload(&row.input)?;
145    tx.execute(
146        INSERT_OUTBOX_SQL,
147        params![
148            row.dispatch_key.clone(),
149            row.workflow_id.to_string(),
150            i64::try_from(row.ordinal).map_err(|_| StoreError::Backend(format!(
151                "outbox ordinal overflow: {}",
152                row.ordinal
153            )))?,
154            row.activity_type.clone(),
155            input,
156            row.status.as_str(),
157            i64::from(row.attempt),
158            encode_instant(row.visible_after),
159            row.claimed_at.map(encode_instant),
160            row.run_id.as_ref().map(ToString::to_string),
161            row.namespace.clone(),
162            row.task_queue.clone(),
163            row.node.clone()
164        ],
165    )
166    .await
167    .map(|_| ())
168    .map_err(|error| crate::error::libsql_error(&error))
169}
170
171/// Insert `rows` under a dedicated `IMMEDIATE` transaction, ignoring duplicate `dispatch_key`s.
172///
173/// # Errors
174///
175/// Returns `StoreError::Serialization` when a row cannot be encoded and `StoreError::Backend` for
176/// libSQL boundary failures.
177pub(crate) async fn append_outbox_batch(
178    conn: &Connection,
179    rows: &[OutboxRow],
180) -> Result<(), StoreError> {
181    if rows.is_empty() {
182        return Ok(());
183    }
184
185    let tx = conn
186        .transaction_with_behavior(TransactionBehavior::Immediate)
187        .await
188        .map_err(|error| crate::error::libsql_error(&error))?;
189
190    for row in rows {
191        if let Err(error) = insert_outbox_row(&tx, row).await {
192            rollback(tx).await?;
193            return Err(error);
194        }
195    }
196
197    tx.commit()
198        .await
199        .map_err(|error| crate::error::libsql_error(&error))
200}
201
202/// Re-arm `rows` to claimable `pending` under one `IMMEDIATE` transaction (crash-recovery re-stage).
203///
204/// Each row is upserted: a brand-new `dispatch_key` is inserted as `pending` with the row's
205/// `attempt` (zero for a fresh [`OutboxRow::pending`]); an existing `dispatch_key` is flipped back to
206/// `status = 'pending'` with `visible_after` reset to the row's instant so it is immediately
207/// claimable. The UPDATE branch deliberately does NOT touch `attempt`, preserving the dispatch retry
208/// budget so a workflow that reliably crashes the server still eventually dead-letters.
209///
210/// # Errors
211///
212/// Returns `StoreError::Serialization` when a row cannot be encoded and `StoreError::Backend` for
213/// libSQL boundary failures.
214pub(crate) async fn rearm_outbox_pending(
215    conn: &Connection,
216    rows: &[OutboxRow],
217) -> Result<(), StoreError> {
218    if rows.is_empty() {
219        return Ok(());
220    }
221
222    let tx = conn
223        .transaction_with_behavior(TransactionBehavior::Immediate)
224        .await
225        .map_err(|error| crate::error::libsql_error(&error))?;
226
227    for row in rows {
228        if let Err(error) = rearm_outbox_row(&tx, row).await {
229            rollback(tx).await?;
230            return Err(error);
231        }
232    }
233
234    tx.commit()
235        .await
236        .map_err(|error| crate::error::libsql_error(&error))
237}
238
239async fn rearm_outbox_row(tx: &Transaction, row: &OutboxRow) -> Result<(), StoreError> {
240    let input = encode_payload(&row.input)?;
241    tx.execute(
242        REARM_OUTBOX_SQL,
243        params![
244            row.dispatch_key.clone(),
245            row.workflow_id.to_string(),
246            i64::try_from(row.ordinal).map_err(|_| StoreError::Backend(format!(
247                "outbox ordinal overflow: {}",
248                row.ordinal
249            )))?,
250            row.activity_type.clone(),
251            input,
252            OutboxStatus::Pending.as_str(),
253            i64::from(row.attempt),
254            encode_instant(row.visible_after),
255            row.namespace.clone(),
256            row.task_queue.clone(),
257            row.node.clone()
258        ],
259    )
260    .await
261    .map(|_| ())
262    .map_err(|error| crate::error::libsql_error(&error))
263}
264
265/// Claim up to `limit` due pending rows, flipping them to `claimed` in one `IMMEDIATE` transaction.
266///
267/// # Errors
268///
269/// Returns `StoreError::Backend` for libSQL boundary failures and `StoreError::Serialization` when a
270/// stored row cannot be decoded.
271pub(crate) async fn claim_outbox_rows(
272    conn: &Connection,
273    limit: u32,
274) -> Result<Vec<OutboxRow>, StoreError> {
275    if limit == 0 {
276        return Ok(Vec::new());
277    }
278
279    let tx = conn
280        .transaction_with_behavior(TransactionBehavior::Immediate)
281        .await
282        .map_err(|error| crate::error::libsql_error(&error))?;
283
284    let claimed = match select_and_claim(&tx, limit).await {
285        Ok(claimed) => claimed,
286        Err(error) => {
287            rollback(tx).await?;
288            return Err(error);
289        }
290    };
291
292    tx.commit()
293        .await
294        .map_err(|error| crate::error::libsql_error(&error))?;
295
296    Ok(claimed)
297}
298
299/// Claim up to `limit` due pending rows whose owning workflow is NOT in `held`
300/// (the pause dispatch-hold, #204).
301///
302/// A held row is skipped in-place: it is never flipped to `claimed`, so it stays
303/// `pending` for the whole paused window. With an empty `held` set this delegates
304/// to [`claim_outbox_rows`] unchanged.
305///
306/// # Errors
307///
308/// Returns `StoreError::Backend` for libSQL boundary failures and `StoreError::Serialization` when a
309/// stored row cannot be decoded.
310pub(crate) async fn claim_outbox_rows_excluding(
311    conn: &Connection,
312    limit: u32,
313    held: &std::collections::HashSet<aion_core::WorkflowId>,
314) -> Result<Vec<OutboxRow>, StoreError> {
315    if limit == 0 {
316        return Ok(Vec::new());
317    }
318    if held.is_empty() {
319        return claim_outbox_rows(conn, limit).await;
320    }
321
322    let tx = conn
323        .transaction_with_behavior(TransactionBehavior::Immediate)
324        .await
325        .map_err(|error| crate::error::libsql_error(&error))?;
326
327    let claimed = match select_and_claim_excluding(&tx, limit, held).await {
328        Ok(claimed) => claimed,
329        Err(error) => {
330            rollback(tx).await?;
331            return Err(error);
332        }
333    };
334
335    tx.commit()
336        .await
337        .map_err(|error| crate::error::libsql_error(&error))?;
338
339    Ok(claimed)
340}
341
342async fn select_and_claim_excluding(
343    tx: &Transaction,
344    limit: u32,
345    held: &std::collections::HashSet<aion_core::WorkflowId>,
346) -> Result<Vec<OutboxRow>, StoreError> {
347    let claimed_at = Utc::now();
348    let now = encode_instant(claimed_at);
349    // Unbounded SELECT + stop-at-limit below: with the held filter applied in Rust,
350    // a SQL LIMIT would let a paused workflow's earliest-due rows fill the whole
351    // window and starve the route (held rows sort first and are never claimed).
352    let mut rows = tx
353        .query(SELECT_CLAIMABLE_UNBOUNDED_SQL, params![now.clone()])
354        .await
355        .map_err(|error| crate::error::libsql_error(&error))?;
356
357    let mut claimed = Vec::new();
358    while let Some(row) = rows
359        .next()
360        .await
361        .map_err(|error| crate::error::libsql_error(&error))?
362    {
363        if claimed.len() >= limit as usize {
364            break;
365        }
366        let decoded = decode_row(&row)?;
367        // A held (paused) workflow's row is left Pending: never claimed, never
368        // released — release is purely resume + the next sweep.
369        if held.contains(&decoded.workflow_id) {
370            continue;
371        }
372        tx.execute(
373            CLAIM_ROW_SQL,
374            params![decoded.dispatch_key.clone(), now.clone()],
375        )
376        .await
377        .map_err(|error| crate::error::libsql_error(&error))?;
378        claimed.push(OutboxRow {
379            status: OutboxStatus::Claimed,
380            claimed_at: Some(claimed_at),
381            ..decoded
382        });
383    }
384
385    Ok(claimed)
386}
387
388async fn select_and_claim(tx: &Transaction, limit: u32) -> Result<Vec<OutboxRow>, StoreError> {
389    let claimed_at = Utc::now();
390    let now = encode_instant(claimed_at);
391    let mut rows = tx
392        .query(SELECT_CLAIMABLE_SQL, params![now.clone(), i64::from(limit)])
393        .await
394        .map_err(|error| crate::error::libsql_error(&error))?;
395
396    let mut claimed = Vec::new();
397    while let Some(row) = rows
398        .next()
399        .await
400        .map_err(|error| crate::error::libsql_error(&error))?
401    {
402        let decoded = decode_row(&row)?;
403        tx.execute(
404            CLAIM_ROW_SQL,
405            params![decoded.dispatch_key.clone(), now.clone()],
406        )
407        .await
408        .map_err(|error| crate::error::libsql_error(&error))?;
409        claimed.push(OutboxRow {
410            status: OutboxStatus::Claimed,
411            claimed_at: Some(claimed_at),
412            ..decoded
413        });
414    }
415
416    Ok(claimed)
417}
418
419/// Claim up to `limit` due pending rows that are in `scope`, atomically (LSUB-1a).
420///
421/// Identical to [`claim_outbox_rows`] but with an additional `(namespace, task_queue, node)` filter
422/// pushed into the SELECT WHERE clause. The unscoped path is untouched.
423///
424/// # Errors
425///
426/// Returns `StoreError::Backend` for libSQL boundary failures and `StoreError::Serialization` when a
427/// stored row cannot be decoded.
428pub(crate) async fn claim_outbox_rows_scoped(
429    conn: &Connection,
430    scope: &ClaimScope,
431    limit: u32,
432) -> Result<Vec<OutboxRow>, StoreError> {
433    claim_outbox_rows_scoped_excluding(conn, scope, limit, &std::collections::HashSet::new()).await
434}
435
436/// Claim up to `limit` due pending rows in `scope` whose owning workflow is NOT in
437/// `held` (the pause dispatch-hold, #204), atomically.
438///
439/// The scoped (backpressure / node-affinity) counterpart to
440/// [`claim_outbox_rows_excluding`]: a held row is skipped in-place and left
441/// `pending` for the whole paused window. With an empty `held` set this is
442/// byte-identical to the plain scoped claim.
443///
444/// # Errors
445///
446/// Returns `StoreError::Backend` for libSQL boundary failures and `StoreError::Serialization` when a
447/// stored row cannot be decoded.
448pub(crate) async fn claim_outbox_rows_scoped_excluding(
449    conn: &Connection,
450    scope: &ClaimScope,
451    limit: u32,
452    held: &std::collections::HashSet<aion_core::WorkflowId>,
453) -> Result<Vec<OutboxRow>, StoreError> {
454    if limit == 0 {
455        return Ok(Vec::new());
456    }
457
458    let tx = conn
459        .transaction_with_behavior(TransactionBehavior::Immediate)
460        .await
461        .map_err(|error| crate::error::libsql_error(&error))?;
462
463    let claimed = match select_and_claim_scoped(&tx, scope, limit, held).await {
464        Ok(claimed) => claimed,
465        Err(error) => {
466            rollback(tx).await?;
467            return Err(error);
468        }
469    };
470
471    tx.commit()
472        .await
473        .map_err(|error| crate::error::libsql_error(&error))?;
474
475    Ok(claimed)
476}
477
478async fn select_and_claim_scoped(
479    tx: &Transaction,
480    scope: &ClaimScope,
481    limit: u32,
482    held: &std::collections::HashSet<aion_core::WorkflowId>,
483) -> Result<Vec<OutboxRow>, StoreError> {
484    let claimed_at = Utc::now();
485    let now = encode_instant(claimed_at);
486
487    // Splice the node predicate into the scoped SELECT. A node-bearing scope serves rows pinned to
488    // that node OR unpinned rows; a node-less scope serves only unpinned rows.
489    let node_clause = if scope.node.is_some() {
490        NODE_CLAUSE_PINNED_OR_UNPINNED
491    } else {
492        NODE_CLAUSE_UNPINNED_ONLY
493    };
494    // With held workflows to skip, the LIMIT moves out of SQL into the claim loop
495    // (stop-at-limit): held rows sort earliest and must not consume the window
496    // (route starvation, #204). The empty-held claim keeps the bounded SQL.
497    let suffix = if held.is_empty() {
498        SELECT_CLAIMABLE_SCOPED_SUFFIX_SQL
499    } else {
500        SELECT_CLAIMABLE_SCOPED_SUFFIX_UNBOUNDED_SQL
501    };
502    let sql = format!("{SELECT_CLAIMABLE_SCOPED_PREFIX_SQL}{node_clause}{suffix}");
503
504    // `?5` is bound only when the scope carries a node; the node-less clause references no `?5`.
505    let mut rows = if let Some(node) = scope.node.as_deref() {
506        tx.query(
507            &sql,
508            params![
509                now.clone(),
510                i64::from(limit),
511                scope.namespace.clone(),
512                scope.task_queue.clone(),
513                node.to_string()
514            ],
515        )
516        .await
517    } else {
518        tx.query(
519            &sql,
520            params![
521                now.clone(),
522                i64::from(limit),
523                scope.namespace.clone(),
524                scope.task_queue.clone()
525            ],
526        )
527        .await
528    }
529    .map_err(|error| crate::error::libsql_error(&error))?;
530
531    let mut claimed = Vec::new();
532    while let Some(row) = rows
533        .next()
534        .await
535        .map_err(|error| crate::error::libsql_error(&error))?
536    {
537        if claimed.len() >= limit as usize {
538            break;
539        }
540        let decoded = decode_row(&row)?;
541        // A held (paused) workflow's row is left Pending: never claimed, never
542        // released — release is purely resume + the next sweep (#204). The
543        // backpressure sweep claims through this scoped path, so the hold MUST be
544        // honoured here too, not only on the unscoped claim.
545        if held.contains(&decoded.workflow_id) {
546            continue;
547        }
548        tx.execute(
549            CLAIM_ROW_SQL,
550            params![decoded.dispatch_key.clone(), now.clone()],
551        )
552        .await
553        .map_err(|error| crate::error::libsql_error(&error))?;
554        claimed.push(OutboxRow {
555            status: OutboxStatus::Claimed,
556            claimed_at: Some(claimed_at),
557            ..decoded
558        });
559    }
560
561    Ok(claimed)
562}
563
564/// Re-arm stale claimed rows to claimable `pending` without changing attempt count.
565///
566/// Rows with `NULL claimed_at` are ignored because no durable claim instant can be compared to the
567/// caller-supplied threshold.
568///
569/// # Errors
570///
571/// Returns `StoreError::Backend` for libSQL boundary failures and `StoreError::Serialization` when a
572/// stored row cannot be decoded.
573pub(crate) async fn rearm_stale_claimed_outbox_rows(
574    conn: &Connection,
575    older_than: DateTime<Utc>,
576    visible_after: DateTime<Utc>,
577    limit: u32,
578) -> Result<Vec<OutboxRow>, StoreError> {
579    if limit == 0 {
580        return Ok(Vec::new());
581    }
582
583    let tx = conn
584        .transaction_with_behavior(TransactionBehavior::Immediate)
585        .await
586        .map_err(|error| crate::error::libsql_error(&error))?;
587
588    let rows = match select_and_rearm_stale_claimed(&tx, older_than, visible_after, limit).await {
589        Ok(rows) => rows,
590        Err(error) => {
591            rollback(tx).await?;
592            return Err(error);
593        }
594    };
595
596    tx.commit()
597        .await
598        .map_err(|error| crate::error::libsql_error(&error))?;
599
600    Ok(rows)
601}
602
603async fn select_and_rearm_stale_claimed(
604    tx: &Transaction,
605    older_than: DateTime<Utc>,
606    visible_after: DateTime<Utc>,
607    limit: u32,
608) -> Result<Vec<OutboxRow>, StoreError> {
609    let mut rows = tx
610        .query(
611            SELECT_STALE_CLAIMED_SQL,
612            params![encode_instant(older_than), i64::from(limit)],
613        )
614        .await
615        .map_err(|error| crate::error::libsql_error(&error))?;
616
617    let mut rearmed = Vec::new();
618    while let Some(row) = rows
619        .next()
620        .await
621        .map_err(|error| crate::error::libsql_error(&error))?
622    {
623        let decoded = decode_row(&row)?;
624        tx.execute(
625            REARM_STALE_CLAIMED_ROW_SQL,
626            params![decoded.dispatch_key.clone(), encode_instant(visible_after)],
627        )
628        .await
629        .map_err(|error| crate::error::libsql_error(&error))?;
630        rearmed.push(OutboxRow {
631            status: OutboxStatus::Pending,
632            visible_after,
633            claimed_at: None,
634            ..decoded
635        });
636    }
637
638    Ok(rearmed)
639}
640
641/// Read up to `limit` stale claimed rows — the exact selection
642/// [`rearm_stale_claimed_outbox_rows`] would re-arm — WITHOUT transitioning them (#253).
643///
644/// Same predicate (claimed, durable `claimed_at` older than `older_than`, `NULL claimed_at`
645/// excluded) and the same `claimed_at ASC, dispatch_key ASC` order, so the reconciler's liveness
646/// probe sees precisely the re-arm candidates.
647///
648/// # Errors
649///
650/// Returns `StoreError::Backend` for libSQL boundary failures and `StoreError::Serialization` when a
651/// stored row cannot be decoded.
652pub(crate) async fn list_stale_claimed_outbox_rows(
653    conn: &Connection,
654    older_than: DateTime<Utc>,
655    limit: u32,
656) -> Result<Vec<OutboxRow>, StoreError> {
657    if limit == 0 {
658        return Ok(Vec::new());
659    }
660    let mut rows = conn
661        .query(
662            SELECT_STALE_CLAIMED_SQL,
663            params![encode_instant(older_than), i64::from(limit)],
664        )
665        .await
666        .map_err(|error| crate::error::libsql_error(&error))?;
667    let mut stale = Vec::new();
668    while let Some(row) = rows
669        .next()
670        .await
671        .map_err(|error| crate::error::libsql_error(&error))?
672    {
673        stale.push(decode_row(&row)?);
674    }
675    Ok(stale)
676}
677
678/// Enumerate the distinct workflow ids owning any live (`pending`|`claimed`) row (#253). Read-only.
679///
680/// # Errors
681///
682/// Returns `StoreError::Backend` for libSQL boundary failures and `StoreError::Serialization` when a
683/// stored workflow id cannot be decoded.
684pub(crate) async fn list_unsettled_outbox_workflow_ids(
685    conn: &Connection,
686) -> Result<Vec<WorkflowId>, StoreError> {
687    let mut rows = conn
688        .query(SELECT_UNSETTLED_WORKFLOW_IDS_SQL, ())
689        .await
690        .map_err(|error| crate::error::libsql_error(&error))?;
691    let mut workflow_ids = Vec::new();
692    while let Some(row) = rows
693        .next()
694        .await
695        .map_err(|error| crate::error::libsql_error(&error))?
696    {
697        let workflow_id: String = row
698            .get(0)
699            .map_err(|error| crate::error::libsql_error(&error))?;
700        workflow_ids.push(decode_workflow_id(&workflow_id)?);
701    }
702    Ok(workflow_ids)
703}
704
705/// Idempotently settle every live (`pending`|`claimed`) row of `workflow_id` to `cancelled`,
706/// returning the settled `dispatch_key`s (#253).
707///
708/// Select-then-update inside one `IMMEDIATE` transaction so the returned keys are exactly the rows
709/// this settle retired; terminal rows (`done`/`failed`/`cancelled`) are never touched, and a
710/// workflow with no live rows settles nothing and returns an empty set.
711///
712/// # Errors
713///
714/// Returns `StoreError::Backend` for libSQL boundary failures.
715pub(crate) async fn settle_workflow_outbox_rows_cancelled(
716    conn: &Connection,
717    workflow_id: &WorkflowId,
718) -> Result<Vec<String>, StoreError> {
719    let tx = conn
720        .transaction_with_behavior(TransactionBehavior::Immediate)
721        .await
722        .map_err(|error| crate::error::libsql_error(&error))?;
723    let settled = match select_and_settle_workflow_rows(&tx, workflow_id).await {
724        Ok(settled) => settled,
725        Err(error) => {
726            rollback(tx).await?;
727            return Err(error);
728        }
729    };
730    tx.commit()
731        .await
732        .map_err(|error| crate::error::libsql_error(&error))?;
733    Ok(settled)
734}
735
736async fn select_and_settle_workflow_rows(
737    tx: &Transaction,
738    workflow_id: &WorkflowId,
739) -> Result<Vec<String>, StoreError> {
740    let mut rows = tx
741        .query(
742            SELECT_LIVE_KEYS_FOR_WORKFLOW_SQL,
743            params![workflow_id.to_string()],
744        )
745        .await
746        .map_err(|error| crate::error::libsql_error(&error))?;
747    let mut settled = Vec::new();
748    while let Some(row) = rows
749        .next()
750        .await
751        .map_err(|error| crate::error::libsql_error(&error))?
752    {
753        let dispatch_key: String = row
754            .get(0)
755            .map_err(|error| crate::error::libsql_error(&error))?;
756        settled.push(dispatch_key);
757    }
758    if !settled.is_empty() {
759        tx.execute(
760            SETTLE_WORKFLOW_LIVE_ROWS_SQL,
761            params![workflow_id.to_string()],
762        )
763        .await
764        .map_err(|error| crate::error::libsql_error(&error))?;
765    }
766    Ok(settled)
767}
768
769/// Out-of-band snapshot of one outbox row's mutable lifecycle bookkeeping.
770///
771/// Read by [`LibSqlStore::outbox_row_state`](crate::LibSqlStore::outbox_row_state) for tests and
772/// operator inspection; payload columns are intentionally omitted.
773#[derive(Clone, Debug, PartialEq, Eq)]
774pub struct OutboxRowState {
775    /// Current lifecycle state.
776    pub status: OutboxStatus,
777    /// Dispatch attempt count.
778    pub attempt: u32,
779    /// Earliest instant at which the row becomes claimable again.
780    pub visible_after: DateTime<Utc>,
781}
782
783const SELECT_ROW_STATE_SQL: &str = "
784SELECT status, attempt, visible_after FROM outbox WHERE dispatch_key = ?1";
785
786/// Read `(status, attempt, visible_after)` for one row, or `None` when absent.
787pub(crate) async fn outbox_row_state(
788    conn: &Connection,
789    dispatch_key: &str,
790) -> Result<Option<OutboxRowState>, StoreError> {
791    let mut rows = conn
792        .query(SELECT_ROW_STATE_SQL, params![dispatch_key.to_string()])
793        .await
794        .map_err(|error| crate::error::libsql_error(&error))?;
795    let Some(row) = rows
796        .next()
797        .await
798        .map_err(|error| crate::error::libsql_error(&error))?
799    else {
800        return Ok(None);
801    };
802    let status: String = row
803        .get(0)
804        .map_err(|error| crate::error::libsql_error(&error))?;
805    let attempt: i64 = row
806        .get(1)
807        .map_err(|error| crate::error::libsql_error(&error))?;
808    let visible_after: String = row
809        .get(2)
810        .map_err(|error| crate::error::libsql_error(&error))?;
811    Ok(Some(OutboxRowState {
812        status: OutboxStatus::parse_token(&status)?,
813        attempt: u32::try_from(attempt)
814            .map_err(|_| StoreError::Backend(format!("outbox attempt out of range: {attempt}")))?,
815        visible_after: decode_instant(&visible_after)?,
816    }))
817}
818
819/// Count the in-flight (`pending` OR `claimed`) outbox rows for `namespace` (CP2-Q1.5).
820///
821/// Pushes the status and namespace predicate into a single `COUNT(*)` so the count is computed
822/// in-engine. A stuck-`claimed` row (dispatched but `mark_done` never landed) is still `claimed`
823/// and so still counts; terminal rows do not.
824///
825/// # Errors
826///
827/// Returns `StoreError::Backend` for libSQL boundary failures and `StoreError::Serialization` when
828/// the engine returns a negative count.
829pub(crate) async fn count_inflight_outbox_rows(
830    conn: &Connection,
831    namespace: &str,
832) -> Result<u64, StoreError> {
833    let mut rows = conn
834        .query(COUNT_INFLIGHT_OUTBOX_SQL, params![namespace.to_string()])
835        .await
836        .map_err(|error| crate::error::libsql_error(&error))?;
837    let Some(row) = rows
838        .next()
839        .await
840        .map_err(|error| crate::error::libsql_error(&error))?
841    else {
842        return Ok(0);
843    };
844    let count: i64 = row
845        .get(0)
846        .map_err(|error| crate::error::libsql_error(&error))?;
847    u64::try_from(count)
848        .map_err(|_| StoreError::Serialization(format!("outbox in-flight count negative: {count}")))
849}
850
851/// Count the CLAIMED (concurrently executing) outbox rows for `namespace` (CP2-Q2).
852///
853/// Narrower than [`count_inflight_outbox_rows`]: it excludes the `pending` backlog and counts only
854/// `claimed` rows, the input the keyed-backpressure ceiling caps so a tenant cannot wedge itself
855/// against its own backlog. A stuck-`claimed` row still counts (the worker may still be executing).
856///
857/// # Errors
858///
859/// Returns `StoreError::Backend` for libSQL boundary failures and `StoreError::Serialization` when
860/// the engine returns a negative count.
861pub(crate) async fn count_claimed_outbox_rows(
862    conn: &Connection,
863    namespace: &str,
864) -> Result<u64, StoreError> {
865    let mut rows = conn
866        .query(COUNT_CLAIMED_OUTBOX_SQL, params![namespace.to_string()])
867        .await
868        .map_err(|error| crate::error::libsql_error(&error))?;
869    let Some(row) = rows
870        .next()
871        .await
872        .map_err(|error| crate::error::libsql_error(&error))?
873    else {
874        return Ok(0);
875    };
876    let count: i64 = row
877        .get(0)
878        .map_err(|error| crate::error::libsql_error(&error))?;
879    u64::try_from(count)
880        .map_err(|_| StoreError::Serialization(format!("outbox claimed count negative: {count}")))
881}
882
883/// Count CLAIMED outbox rows per namespace in ONE grouped query, filtered to `namespaces` (CP2-Q2 perf).
884///
885/// Byte-identical to calling [`count_claimed_outbox_rows`] once per namespace — same `claimed`-only
886/// predicate — but a single round-trip instead of N. Every requested namespace is present in the
887/// returned map (seeded to `0`) so the caller can index it unconditionally; namespaces outside the
888/// request are dropped.
889///
890/// # Errors
891///
892/// Returns `StoreError::Backend` for libSQL boundary failures and `StoreError::Serialization` when the
893/// engine returns a negative count.
894pub(crate) async fn count_claimed_outbox_rows_by_namespace(
895    conn: &Connection,
896    namespaces: &[&str],
897) -> Result<std::collections::BTreeMap<String, u64>, StoreError> {
898    let requested: std::collections::BTreeSet<&str> = namespaces.iter().copied().collect();
899    let mut counts: std::collections::BTreeMap<String, u64> =
900        requested.iter().map(|ns| ((*ns).to_owned(), 0)).collect();
901    let mut rows = conn
902        .query(COUNT_CLAIMED_OUTBOX_BY_NAMESPACE_SQL, ())
903        .await
904        .map_err(|error| crate::error::libsql_error(&error))?;
905    while let Some(row) = rows
906        .next()
907        .await
908        .map_err(|error| crate::error::libsql_error(&error))?
909    {
910        let namespace: String = row
911            .get(0)
912            .map_err(|error| crate::error::libsql_error(&error))?;
913        if !requested.contains(namespace.as_str()) {
914            continue;
915        }
916        let count: i64 = row
917            .get(1)
918            .map_err(|error| crate::error::libsql_error(&error))?;
919        let count = u64::try_from(count).map_err(|_| {
920            StoreError::Serialization(format!("outbox claimed count negative: {count}"))
921        })?;
922        counts.insert(namespace, count);
923    }
924    Ok(counts)
925}
926
927/// Enumerate the distinct `(namespace, task_queue, node)` routes with a claimable pending row (CP2-Q2).
928///
929/// Read-only: claims nothing, only reports which [`ClaimScope`]s have due work so the dispatcher can
930/// round-robin a scoped, headroom-capped claim per route. A NULL `node` decodes to `None` (unpinned),
931/// rebuilding the exact scope the rows live under.
932///
933/// # Errors
934///
935/// Returns `StoreError::Backend` for libSQL boundary failures and `StoreError::Serialization` when a
936/// stored route column cannot be decoded.
937pub(crate) async fn pending_outbox_routes(
938    conn: &Connection,
939) -> Result<Vec<ClaimScope>, StoreError> {
940    let now = encode_instant(Utc::now());
941    let mut rows = conn
942        .query(PENDING_OUTBOX_ROUTES_SQL, params![now])
943        .await
944        .map_err(|error| crate::error::libsql_error(&error))?;
945    let mut routes = Vec::new();
946    while let Some(row) = rows
947        .next()
948        .await
949        .map_err(|error| crate::error::libsql_error(&error))?
950    {
951        // Legacy rows persisted before NSTQ-2 read NULL namespace/task_queue back as `"default"`,
952        // matching `decode_row`, so the probed scope routes identically to the row.
953        let namespace: Option<String> = row
954            .get(0)
955            .map_err(|error| crate::error::libsql_error(&error))?;
956        let task_queue: Option<String> = row
957            .get(1)
958            .map_err(|error| crate::error::libsql_error(&error))?;
959        let node: Option<String> = row
960            .get(2)
961            .map_err(|error| crate::error::libsql_error(&error))?;
962        routes.push(
963            ClaimScope::new(
964                namespace.unwrap_or_else(|| String::from(DEFAULT_OUTBOX_ROUTE)),
965                task_queue.unwrap_or_else(|| String::from(DEFAULT_OUTBOX_ROUTE)),
966            )
967            .with_node(node),
968        );
969    }
970    Ok(routes)
971}
972
973fn decode_row(row: &Row) -> Result<OutboxRow, StoreError> {
974    let dispatch_key: String = row
975        .get(0)
976        .map_err(|error| crate::error::libsql_error(&error))?;
977    let workflow_id: String = row
978        .get(1)
979        .map_err(|error| crate::error::libsql_error(&error))?;
980    let ordinal: i64 = row
981        .get(2)
982        .map_err(|error| crate::error::libsql_error(&error))?;
983    let activity_type: String = row
984        .get(3)
985        .map_err(|error| crate::error::libsql_error(&error))?;
986    let input: Vec<u8> = row
987        .get(4)
988        .map_err(|error| crate::error::libsql_error(&error))?;
989    let status: String = row
990        .get(5)
991        .map_err(|error| crate::error::libsql_error(&error))?;
992    let attempt: i64 = row
993        .get(6)
994        .map_err(|error| crate::error::libsql_error(&error))?;
995    let visible_after: String = row
996        .get(7)
997        .map_err(|error| crate::error::libsql_error(&error))?;
998    let claimed_at: Option<String> = row
999        .get(8)
1000        .map_err(|error| crate::error::libsql_error(&error))?;
1001    let run_id: Option<String> = row
1002        .get(9)
1003        .map_err(|error| crate::error::libsql_error(&error))?;
1004    // Legacy rows persisted before NSTQ-2 added these columns read back as NULL; resolve them to the
1005    // `"default"` routing identity so the dispatcher has a concrete namespace + task queue.
1006    let namespace: Option<String> = row
1007        .get(10)
1008        .map_err(|error| crate::error::libsql_error(&error))?;
1009    let task_queue: Option<String> = row
1010        .get(11)
1011        .map_err(|error| crate::error::libsql_error(&error))?;
1012    // Node affinity is OPTIONAL: a NULL column (including legacy rows persisted before NODE-2 added
1013    // the column) decodes to `None` = no affinity. There is no sentinel string.
1014    let node: Option<String> = row
1015        .get(12)
1016        .map_err(|error| crate::error::libsql_error(&error))?;
1017
1018    Ok(OutboxRow {
1019        dispatch_key,
1020        workflow_id: decode_workflow_id(&workflow_id)?,
1021        ordinal: u64::try_from(ordinal)
1022            .map_err(|_| StoreError::Backend(format!("outbox ordinal was negative: {ordinal}")))?,
1023        activity_type,
1024        input: decode_payload(&input)?,
1025        status: OutboxStatus::parse_token(&status)?,
1026        attempt: u32::try_from(attempt)
1027            .map_err(|_| StoreError::Backend(format!("outbox attempt out of range: {attempt}")))?,
1028        visible_after: decode_instant(&visible_after)?,
1029        claimed_at: claimed_at.as_deref().map(decode_instant).transpose()?,
1030        run_id: run_id.as_deref().map(decode_run_id).transpose()?,
1031        namespace: namespace.unwrap_or_else(|| String::from(DEFAULT_OUTBOX_ROUTE)),
1032        task_queue: task_queue.unwrap_or_else(|| String::from(DEFAULT_OUTBOX_ROUTE)),
1033        node,
1034    })
1035}
1036
1037fn encode_payload(payload: &Payload) -> Result<Vec<u8>, StoreError> {
1038    serde_json::to_vec(payload).map_err(|error| crate::error::serde_json_error(&error))
1039}
1040
1041fn decode_payload(bytes: &[u8]) -> Result<Payload, StoreError> {
1042    serde_json::from_slice(bytes).map_err(|error| crate::error::serde_json_error(&error))
1043}
1044
1045fn decode_workflow_id(value: &str) -> Result<WorkflowId, StoreError> {
1046    uuid::Uuid::parse_str(value)
1047        .map(WorkflowId::new)
1048        .map_err(|error| StoreError::Serialization(format!("invalid outbox workflow id: {error}")))
1049}
1050
1051fn decode_run_id(value: &str) -> Result<RunId, StoreError> {
1052    uuid::Uuid::parse_str(value)
1053        .map(RunId::new)
1054        .map_err(|error| StoreError::Serialization(format!("invalid outbox run id: {error}")))
1055}
1056
1057fn encode_instant(instant: DateTime<Utc>) -> String {
1058    instant.to_rfc3339_opts(SecondsFormat::Nanos, true)
1059}
1060
1061fn decode_instant(value: &str) -> Result<DateTime<Utc>, StoreError> {
1062    DateTime::parse_from_rfc3339(value)
1063        .map(|date_time| date_time.with_timezone(&Utc))
1064        .map_err(|error| StoreError::Serialization(error.to_string()))
1065}
1066
1067async fn rollback(tx: Transaction) -> Result<(), StoreError> {
1068    tx.rollback()
1069        .await
1070        .map_err(|error| crate::error::libsql_error(&error))
1071}