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// Scoped claim (LSUB-1a): additionally constrain by the pool's `(namespace, task_queue)` and the
39// node predicate. The node clause is appended per-scope: a node-bearing scope claims rows pinned to
40// that node OR unpinned rows (`node IS NULL`); a node-less scope claims only unpinned rows. This is
41// the byte-identical due/order/limit claim with an extra WHERE conjunction.
42const SELECT_CLAIMABLE_SCOPED_PREFIX_SQL: &str = "
43SELECT dispatch_key, workflow_id, ordinal, activity_type, input, status, attempt, visible_after, claimed_at, run_id, namespace, task_queue, node
44FROM outbox
45WHERE status = 'pending' AND visible_after <= ?1 AND namespace = ?3 AND task_queue = ?4";
46
47const SELECT_CLAIMABLE_SCOPED_SUFFIX_SQL: &str = "
48ORDER BY visible_after ASC, dispatch_key ASC
49LIMIT ?2";
50
51// Node predicate fragments spliced between the prefix and suffix above. `?5` binds the scope node.
52const NODE_CLAUSE_PINNED_OR_UNPINNED: &str = " AND (node IS NULL OR node = ?5)";
53const NODE_CLAUSE_UNPINNED_ONLY: &str = " AND node IS NULL";
54
55const CLAIM_ROW_SQL: &str = "
56UPDATE outbox SET status = 'claimed', claimed_at = ?2 WHERE dispatch_key = ?1 AND status = 'pending'";
57
58const SELECT_STALE_CLAIMED_SQL: &str = "
59SELECT dispatch_key, workflow_id, ordinal, activity_type, input, status, attempt, visible_after, claimed_at, run_id, namespace, task_queue, node
60FROM outbox
61WHERE status = 'claimed' AND claimed_at IS NOT NULL AND claimed_at < ?1
62ORDER BY claimed_at ASC, dispatch_key ASC
63LIMIT ?2";
64
65const REARM_STALE_CLAIMED_ROW_SQL: &str = "
66UPDATE outbox SET status = 'pending', visible_after = ?2, claimed_at = NULL
67WHERE dispatch_key = ?1 AND status = 'claimed'";
68
69/// Insert a single outbox row inside an existing transaction.
70///
71/// Shared by the standalone [`append_outbox_batch`] and the atomic-with-history `append_with_outbox`
72/// path so both honour the `INSERT OR IGNORE` idempotency guard identically.
73///
74/// # Errors
75///
76/// Returns `StoreError::Serialization` when the input payload cannot be encoded and
77/// `StoreError::Backend` for libSQL boundary failures.
78pub(crate) async fn insert_outbox_row(tx: &Transaction, row: &OutboxRow) -> Result<(), StoreError> {
79    let input = encode_payload(&row.input)?;
80    tx.execute(
81        INSERT_OUTBOX_SQL,
82        params![
83            row.dispatch_key.clone(),
84            row.workflow_id.to_string(),
85            i64::try_from(row.ordinal).map_err(|_| StoreError::Backend(format!(
86                "outbox ordinal overflow: {}",
87                row.ordinal
88            )))?,
89            row.activity_type.clone(),
90            input,
91            row.status.as_str(),
92            i64::from(row.attempt),
93            encode_instant(row.visible_after),
94            row.claimed_at.map(encode_instant),
95            row.run_id.as_ref().map(ToString::to_string),
96            row.namespace.clone(),
97            row.task_queue.clone(),
98            row.node.clone()
99        ],
100    )
101    .await
102    .map(|_| ())
103    .map_err(|error| crate::error::libsql_error(&error))
104}
105
106/// Insert `rows` under a dedicated `IMMEDIATE` transaction, ignoring duplicate `dispatch_key`s.
107///
108/// # Errors
109///
110/// Returns `StoreError::Serialization` when a row cannot be encoded and `StoreError::Backend` for
111/// libSQL boundary failures.
112pub(crate) async fn append_outbox_batch(
113    conn: &Connection,
114    rows: &[OutboxRow],
115) -> Result<(), StoreError> {
116    if rows.is_empty() {
117        return Ok(());
118    }
119
120    let tx = conn
121        .transaction_with_behavior(TransactionBehavior::Immediate)
122        .await
123        .map_err(|error| crate::error::libsql_error(&error))?;
124
125    for row in rows {
126        if let Err(error) = insert_outbox_row(&tx, row).await {
127            rollback(tx).await?;
128            return Err(error);
129        }
130    }
131
132    tx.commit()
133        .await
134        .map_err(|error| crate::error::libsql_error(&error))
135}
136
137/// Re-arm `rows` to claimable `pending` under one `IMMEDIATE` transaction (crash-recovery re-stage).
138///
139/// Each row is upserted: a brand-new `dispatch_key` is inserted as `pending` with the row's
140/// `attempt` (zero for a fresh [`OutboxRow::pending`]); an existing `dispatch_key` is flipped back to
141/// `status = 'pending'` with `visible_after` reset to the row's instant so it is immediately
142/// claimable. The UPDATE branch deliberately does NOT touch `attempt`, preserving the dispatch retry
143/// budget so a workflow that reliably crashes the server still eventually dead-letters.
144///
145/// # Errors
146///
147/// Returns `StoreError::Serialization` when a row cannot be encoded and `StoreError::Backend` for
148/// libSQL boundary failures.
149pub(crate) async fn rearm_outbox_pending(
150    conn: &Connection,
151    rows: &[OutboxRow],
152) -> Result<(), StoreError> {
153    if rows.is_empty() {
154        return Ok(());
155    }
156
157    let tx = conn
158        .transaction_with_behavior(TransactionBehavior::Immediate)
159        .await
160        .map_err(|error| crate::error::libsql_error(&error))?;
161
162    for row in rows {
163        if let Err(error) = rearm_outbox_row(&tx, row).await {
164            rollback(tx).await?;
165            return Err(error);
166        }
167    }
168
169    tx.commit()
170        .await
171        .map_err(|error| crate::error::libsql_error(&error))
172}
173
174async fn rearm_outbox_row(tx: &Transaction, row: &OutboxRow) -> Result<(), StoreError> {
175    let input = encode_payload(&row.input)?;
176    tx.execute(
177        REARM_OUTBOX_SQL,
178        params![
179            row.dispatch_key.clone(),
180            row.workflow_id.to_string(),
181            i64::try_from(row.ordinal).map_err(|_| StoreError::Backend(format!(
182                "outbox ordinal overflow: {}",
183                row.ordinal
184            )))?,
185            row.activity_type.clone(),
186            input,
187            OutboxStatus::Pending.as_str(),
188            i64::from(row.attempt),
189            encode_instant(row.visible_after),
190            row.namespace.clone(),
191            row.task_queue.clone(),
192            row.node.clone()
193        ],
194    )
195    .await
196    .map(|_| ())
197    .map_err(|error| crate::error::libsql_error(&error))
198}
199
200/// Claim up to `limit` due pending rows, flipping them to `claimed` in one `IMMEDIATE` transaction.
201///
202/// # Errors
203///
204/// Returns `StoreError::Backend` for libSQL boundary failures and `StoreError::Serialization` when a
205/// stored row cannot be decoded.
206pub(crate) async fn claim_outbox_rows(
207    conn: &Connection,
208    limit: u32,
209) -> Result<Vec<OutboxRow>, StoreError> {
210    if limit == 0 {
211        return Ok(Vec::new());
212    }
213
214    let tx = conn
215        .transaction_with_behavior(TransactionBehavior::Immediate)
216        .await
217        .map_err(|error| crate::error::libsql_error(&error))?;
218
219    let claimed = match select_and_claim(&tx, limit).await {
220        Ok(claimed) => claimed,
221        Err(error) => {
222            rollback(tx).await?;
223            return Err(error);
224        }
225    };
226
227    tx.commit()
228        .await
229        .map_err(|error| crate::error::libsql_error(&error))?;
230
231    Ok(claimed)
232}
233
234async fn select_and_claim(tx: &Transaction, limit: u32) -> Result<Vec<OutboxRow>, StoreError> {
235    let claimed_at = Utc::now();
236    let now = encode_instant(claimed_at);
237    let mut rows = tx
238        .query(SELECT_CLAIMABLE_SQL, params![now.clone(), i64::from(limit)])
239        .await
240        .map_err(|error| crate::error::libsql_error(&error))?;
241
242    let mut claimed = Vec::new();
243    while let Some(row) = rows
244        .next()
245        .await
246        .map_err(|error| crate::error::libsql_error(&error))?
247    {
248        let decoded = decode_row(&row)?;
249        tx.execute(
250            CLAIM_ROW_SQL,
251            params![decoded.dispatch_key.clone(), now.clone()],
252        )
253        .await
254        .map_err(|error| crate::error::libsql_error(&error))?;
255        claimed.push(OutboxRow {
256            status: OutboxStatus::Claimed,
257            claimed_at: Some(claimed_at),
258            ..decoded
259        });
260    }
261
262    Ok(claimed)
263}
264
265/// Claim up to `limit` due pending rows that are in `scope`, atomically (LSUB-1a).
266///
267/// Identical to [`claim_outbox_rows`] but with an additional `(namespace, task_queue, node)` filter
268/// pushed into the SELECT WHERE clause. The unscoped path is untouched.
269///
270/// # Errors
271///
272/// Returns `StoreError::Backend` for libSQL boundary failures and `StoreError::Serialization` when a
273/// stored row cannot be decoded.
274pub(crate) async fn claim_outbox_rows_scoped(
275    conn: &Connection,
276    scope: &ClaimScope,
277    limit: u32,
278) -> Result<Vec<OutboxRow>, StoreError> {
279    if limit == 0 {
280        return Ok(Vec::new());
281    }
282
283    let tx = conn
284        .transaction_with_behavior(TransactionBehavior::Immediate)
285        .await
286        .map_err(|error| crate::error::libsql_error(&error))?;
287
288    let claimed = match select_and_claim_scoped(&tx, scope, limit).await {
289        Ok(claimed) => claimed,
290        Err(error) => {
291            rollback(tx).await?;
292            return Err(error);
293        }
294    };
295
296    tx.commit()
297        .await
298        .map_err(|error| crate::error::libsql_error(&error))?;
299
300    Ok(claimed)
301}
302
303async fn select_and_claim_scoped(
304    tx: &Transaction,
305    scope: &ClaimScope,
306    limit: u32,
307) -> Result<Vec<OutboxRow>, StoreError> {
308    let claimed_at = Utc::now();
309    let now = encode_instant(claimed_at);
310
311    // Splice the node predicate into the scoped SELECT. A node-bearing scope serves rows pinned to
312    // that node OR unpinned rows; a node-less scope serves only unpinned rows.
313    let node_clause = if scope.node.is_some() {
314        NODE_CLAUSE_PINNED_OR_UNPINNED
315    } else {
316        NODE_CLAUSE_UNPINNED_ONLY
317    };
318    let sql = format!(
319        "{SELECT_CLAIMABLE_SCOPED_PREFIX_SQL}{node_clause}{SELECT_CLAIMABLE_SCOPED_SUFFIX_SQL}"
320    );
321
322    // `?5` is bound only when the scope carries a node; the node-less clause references no `?5`.
323    let mut rows = if let Some(node) = scope.node.as_deref() {
324        tx.query(
325            &sql,
326            params![
327                now.clone(),
328                i64::from(limit),
329                scope.namespace.clone(),
330                scope.task_queue.clone(),
331                node.to_string()
332            ],
333        )
334        .await
335    } else {
336        tx.query(
337            &sql,
338            params![
339                now.clone(),
340                i64::from(limit),
341                scope.namespace.clone(),
342                scope.task_queue.clone()
343            ],
344        )
345        .await
346    }
347    .map_err(|error| crate::error::libsql_error(&error))?;
348
349    let mut claimed = Vec::new();
350    while let Some(row) = rows
351        .next()
352        .await
353        .map_err(|error| crate::error::libsql_error(&error))?
354    {
355        let decoded = decode_row(&row)?;
356        tx.execute(
357            CLAIM_ROW_SQL,
358            params![decoded.dispatch_key.clone(), now.clone()],
359        )
360        .await
361        .map_err(|error| crate::error::libsql_error(&error))?;
362        claimed.push(OutboxRow {
363            status: OutboxStatus::Claimed,
364            claimed_at: Some(claimed_at),
365            ..decoded
366        });
367    }
368
369    Ok(claimed)
370}
371
372/// Re-arm stale claimed rows to claimable `pending` without changing attempt count.
373///
374/// Rows with `NULL claimed_at` are ignored because no durable claim instant can be compared to the
375/// caller-supplied threshold.
376///
377/// # Errors
378///
379/// Returns `StoreError::Backend` for libSQL boundary failures and `StoreError::Serialization` when a
380/// stored row cannot be decoded.
381pub(crate) async fn rearm_stale_claimed_outbox_rows(
382    conn: &Connection,
383    older_than: DateTime<Utc>,
384    visible_after: DateTime<Utc>,
385    limit: u32,
386) -> Result<Vec<OutboxRow>, StoreError> {
387    if limit == 0 {
388        return Ok(Vec::new());
389    }
390
391    let tx = conn
392        .transaction_with_behavior(TransactionBehavior::Immediate)
393        .await
394        .map_err(|error| crate::error::libsql_error(&error))?;
395
396    let rows = match select_and_rearm_stale_claimed(&tx, older_than, visible_after, limit).await {
397        Ok(rows) => rows,
398        Err(error) => {
399            rollback(tx).await?;
400            return Err(error);
401        }
402    };
403
404    tx.commit()
405        .await
406        .map_err(|error| crate::error::libsql_error(&error))?;
407
408    Ok(rows)
409}
410
411async fn select_and_rearm_stale_claimed(
412    tx: &Transaction,
413    older_than: DateTime<Utc>,
414    visible_after: DateTime<Utc>,
415    limit: u32,
416) -> Result<Vec<OutboxRow>, StoreError> {
417    let mut rows = tx
418        .query(
419            SELECT_STALE_CLAIMED_SQL,
420            params![encode_instant(older_than), i64::from(limit)],
421        )
422        .await
423        .map_err(|error| crate::error::libsql_error(&error))?;
424
425    let mut rearmed = Vec::new();
426    while let Some(row) = rows
427        .next()
428        .await
429        .map_err(|error| crate::error::libsql_error(&error))?
430    {
431        let decoded = decode_row(&row)?;
432        tx.execute(
433            REARM_STALE_CLAIMED_ROW_SQL,
434            params![decoded.dispatch_key.clone(), encode_instant(visible_after)],
435        )
436        .await
437        .map_err(|error| crate::error::libsql_error(&error))?;
438        rearmed.push(OutboxRow {
439            status: OutboxStatus::Pending,
440            visible_after,
441            claimed_at: None,
442            ..decoded
443        });
444    }
445
446    Ok(rearmed)
447}
448
449/// Out-of-band snapshot of one outbox row's mutable lifecycle bookkeeping.
450///
451/// Read by [`LibSqlStore::outbox_row_state`](crate::LibSqlStore::outbox_row_state) for tests and
452/// operator inspection; payload columns are intentionally omitted.
453#[derive(Clone, Debug, PartialEq, Eq)]
454pub struct OutboxRowState {
455    /// Current lifecycle state.
456    pub status: OutboxStatus,
457    /// Dispatch attempt count.
458    pub attempt: u32,
459    /// Earliest instant at which the row becomes claimable again.
460    pub visible_after: DateTime<Utc>,
461}
462
463const SELECT_ROW_STATE_SQL: &str = "
464SELECT status, attempt, visible_after FROM outbox WHERE dispatch_key = ?1";
465
466/// Read `(status, attempt, visible_after)` for one row, or `None` when absent.
467pub(crate) async fn outbox_row_state(
468    conn: &Connection,
469    dispatch_key: &str,
470) -> Result<Option<OutboxRowState>, StoreError> {
471    let mut rows = conn
472        .query(SELECT_ROW_STATE_SQL, params![dispatch_key.to_string()])
473        .await
474        .map_err(|error| crate::error::libsql_error(&error))?;
475    let Some(row) = rows
476        .next()
477        .await
478        .map_err(|error| crate::error::libsql_error(&error))?
479    else {
480        return Ok(None);
481    };
482    let status: String = row
483        .get(0)
484        .map_err(|error| crate::error::libsql_error(&error))?;
485    let attempt: i64 = row
486        .get(1)
487        .map_err(|error| crate::error::libsql_error(&error))?;
488    let visible_after: String = row
489        .get(2)
490        .map_err(|error| crate::error::libsql_error(&error))?;
491    Ok(Some(OutboxRowState {
492        status: OutboxStatus::parse_token(&status)?,
493        attempt: u32::try_from(attempt)
494            .map_err(|_| StoreError::Backend(format!("outbox attempt out of range: {attempt}")))?,
495        visible_after: decode_instant(&visible_after)?,
496    }))
497}
498
499fn decode_row(row: &Row) -> Result<OutboxRow, StoreError> {
500    let dispatch_key: String = row
501        .get(0)
502        .map_err(|error| crate::error::libsql_error(&error))?;
503    let workflow_id: String = row
504        .get(1)
505        .map_err(|error| crate::error::libsql_error(&error))?;
506    let ordinal: i64 = row
507        .get(2)
508        .map_err(|error| crate::error::libsql_error(&error))?;
509    let activity_type: String = row
510        .get(3)
511        .map_err(|error| crate::error::libsql_error(&error))?;
512    let input: Vec<u8> = row
513        .get(4)
514        .map_err(|error| crate::error::libsql_error(&error))?;
515    let status: String = row
516        .get(5)
517        .map_err(|error| crate::error::libsql_error(&error))?;
518    let attempt: i64 = row
519        .get(6)
520        .map_err(|error| crate::error::libsql_error(&error))?;
521    let visible_after: String = row
522        .get(7)
523        .map_err(|error| crate::error::libsql_error(&error))?;
524    let claimed_at: Option<String> = row
525        .get(8)
526        .map_err(|error| crate::error::libsql_error(&error))?;
527    let run_id: Option<String> = row
528        .get(9)
529        .map_err(|error| crate::error::libsql_error(&error))?;
530    // Legacy rows persisted before NSTQ-2 added these columns read back as NULL; resolve them to the
531    // `"default"` routing identity so the dispatcher has a concrete namespace + task queue.
532    let namespace: Option<String> = row
533        .get(10)
534        .map_err(|error| crate::error::libsql_error(&error))?;
535    let task_queue: Option<String> = row
536        .get(11)
537        .map_err(|error| crate::error::libsql_error(&error))?;
538    // Node affinity is OPTIONAL: a NULL column (including legacy rows persisted before NODE-2 added
539    // the column) decodes to `None` = no affinity. There is no sentinel string.
540    let node: Option<String> = row
541        .get(12)
542        .map_err(|error| crate::error::libsql_error(&error))?;
543
544    Ok(OutboxRow {
545        dispatch_key,
546        workflow_id: decode_workflow_id(&workflow_id)?,
547        ordinal: u64::try_from(ordinal)
548            .map_err(|_| StoreError::Backend(format!("outbox ordinal was negative: {ordinal}")))?,
549        activity_type,
550        input: decode_payload(&input)?,
551        status: OutboxStatus::parse_token(&status)?,
552        attempt: u32::try_from(attempt)
553            .map_err(|_| StoreError::Backend(format!("outbox attempt out of range: {attempt}")))?,
554        visible_after: decode_instant(&visible_after)?,
555        claimed_at: claimed_at.as_deref().map(decode_instant).transpose()?,
556        run_id: run_id.as_deref().map(decode_run_id).transpose()?,
557        namespace: namespace.unwrap_or_else(|| String::from(DEFAULT_OUTBOX_ROUTE)),
558        task_queue: task_queue.unwrap_or_else(|| String::from(DEFAULT_OUTBOX_ROUTE)),
559        node,
560    })
561}
562
563fn encode_payload(payload: &Payload) -> Result<Vec<u8>, StoreError> {
564    serde_json::to_vec(payload).map_err(|error| crate::error::serde_json_error(&error))
565}
566
567fn decode_payload(bytes: &[u8]) -> Result<Payload, StoreError> {
568    serde_json::from_slice(bytes).map_err(|error| crate::error::serde_json_error(&error))
569}
570
571fn decode_workflow_id(value: &str) -> Result<WorkflowId, StoreError> {
572    uuid::Uuid::parse_str(value)
573        .map(WorkflowId::new)
574        .map_err(|error| StoreError::Serialization(format!("invalid outbox workflow id: {error}")))
575}
576
577fn decode_run_id(value: &str) -> Result<RunId, StoreError> {
578    uuid::Uuid::parse_str(value)
579        .map(RunId::new)
580        .map_err(|error| StoreError::Serialization(format!("invalid outbox run id: {error}")))
581}
582
583fn encode_instant(instant: DateTime<Utc>) -> String {
584    instant.to_rfc3339_opts(SecondsFormat::Nanos, true)
585}
586
587fn decode_instant(value: &str) -> Result<DateTime<Utc>, StoreError> {
588    DateTime::parse_from_rfc3339(value)
589        .map(|date_time| date_time.with_timezone(&Utc))
590        .map_err(|error| StoreError::Serialization(error.to_string()))
591}
592
593async fn rollback(tx: Transaction) -> Result<(), StoreError> {
594    tx.rollback()
595        .await
596        .map_err(|error| crate::error::libsql_error(&error))
597}