1use 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
38const 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
51const 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
69pub(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
106pub(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
137pub(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
200pub(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
265pub(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 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 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
372pub(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#[derive(Clone, Debug, PartialEq, Eq)]
454pub struct OutboxRowState {
455 pub status: OutboxStatus,
457 pub attempt: u32,
459 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
466pub(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 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 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}