use std::sync::Arc;
use std::time::Duration;
use aion_core::ActivityId;
use aion_store::{OutboxRow, OutboxStore};
use async_trait::async_trait;
use chrono::Utc;
use tokio::sync::watch;
use tracing::{error, info, warn};
use crate::error::ServerError;
use crate::worker::{ActivityDispatcher, ScheduledActivity};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OutboxDispatcherConfig {
pub poll_interval: Duration,
pub batch_size: u32,
pub max_attempts: u32,
pub backoff_base: Duration,
pub backoff_multiplier: u32,
pub backoff_max: Duration,
}
impl OutboxDispatcherConfig {
#[must_use]
pub fn backoff_for_attempt(&self, attempt: u32) -> Duration {
let max_ms = u128::from(u64::MAX);
let multiplier = u128::from(self.backoff_multiplier);
let mut delay_ms = self.backoff_base.as_millis().min(max_ms);
for _ in 0..attempt {
delay_ms = delay_ms.saturating_mul(multiplier);
if delay_ms >= max_ms {
break;
}
}
let cap_ms = self.backoff_max.as_millis().min(max_ms);
let clamped_ms = delay_ms.min(cap_ms);
Duration::from_millis(u64::try_from(clamped_ms).unwrap_or(u64::MAX))
}
}
#[async_trait]
pub trait OutboxRowDispatch: Send + Sync + 'static {
async fn dispatch(&self, row: &OutboxRow) -> Result<(), ServerError>;
}
pub struct WorkerOutboxDispatch {
dispatcher: ActivityDispatcher,
}
impl std::fmt::Debug for WorkerOutboxDispatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WorkerOutboxDispatch")
.finish_non_exhaustive()
}
}
impl WorkerOutboxDispatch {
#[must_use]
pub fn new(dispatcher: ActivityDispatcher) -> Self {
Self { dispatcher }
}
fn to_scheduled(row: &OutboxRow) -> ScheduledActivity {
ScheduledActivity {
namespace: row.namespace.clone(),
task_queue: row.task_queue.clone(),
activity_type: row.activity_type.clone(),
node: row.node.clone(),
workflow_id: row.workflow_id.clone(),
activity_id: ActivityId::from_sequence_position(row.ordinal),
run_id: row.run_id.clone(),
input: row.input.clone(),
attempt: row.attempt.saturating_add(1),
labels: std::collections::BTreeMap::new(),
}
}
}
#[async_trait]
impl OutboxRowDispatch for WorkerOutboxDispatch {
async fn dispatch(&self, row: &OutboxRow) -> Result<(), ServerError> {
self.dispatcher.dispatch(&Self::to_scheduled(row)).await
}
}
pub struct OutboxDispatcher {
store: Arc<dyn OutboxStore>,
dispatch: Arc<dyn OutboxRowDispatch>,
config: OutboxDispatcherConfig,
wake: Arc<tokio::sync::Notify>,
}
impl std::fmt::Debug for OutboxDispatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OutboxDispatcher")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl OutboxDispatcher {
#[must_use]
pub fn new(
store: Arc<dyn OutboxStore>,
dispatch: Arc<dyn OutboxRowDispatch>,
config: OutboxDispatcherConfig,
) -> Self {
Self {
store,
dispatch,
config,
wake: Arc::new(tokio::sync::Notify::new()),
}
}
#[must_use]
pub fn with_wake(mut self, wake: Arc<tokio::sync::Notify>) -> Self {
self.wake = wake;
self
}
pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
info!(
poll_interval_ms = self.config.poll_interval.as_millis(),
batch_size = self.config.batch_size,
max_attempts = self.config.max_attempts,
"outbox dispatcher started"
);
let mut interval = tokio::time::interval(self.config.poll_interval);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = interval.tick() => {
if *shutdown.borrow() {
break;
}
self.sweep_once().await;
}
() = self.wake.notified() => {
if *shutdown.borrow() {
break;
}
self.sweep_once().await;
}
changed = shutdown.changed() => {
if changed.is_err() || *shutdown.borrow() {
break;
}
}
}
}
info!("outbox dispatcher stopped");
}
async fn sweep_once(&self) {
let rows = match self.store.claim_outbox_rows(self.config.batch_size).await {
Ok(rows) => rows,
Err(error) => {
error!(%error, "outbox dispatcher failed to claim rows; retrying next tick");
return;
}
};
for row in rows {
self.process_row(&row).await;
}
}
async fn process_row(&self, row: &OutboxRow) {
match self.dispatch.dispatch(row).await {
Ok(()) => self.mark_done(row).await,
Err(error) => self.handle_dispatch_error(row, &error).await,
}
}
async fn mark_done(&self, row: &OutboxRow) {
if let Err(error) = self.store.complete_outbox_row(&row.dispatch_key).await {
error!(
dispatch_key = %row.dispatch_key,
%error,
"outbox dispatcher dispatched a row but failed to mark it done"
);
}
}
async fn handle_dispatch_error(&self, row: &OutboxRow, dispatch_error: &ServerError) {
let attempted = row.attempt.saturating_add(1);
if attempted >= self.config.max_attempts {
warn!(
dispatch_key = %row.dispatch_key,
attempt = row.attempt,
max_attempts = self.config.max_attempts,
error = %dispatch_error,
"outbox dispatch exhausted retry budget; dead-lettering row"
);
if let Err(error) = self.store.fail_outbox_row(&row.dispatch_key).await {
error!(dispatch_key = %row.dispatch_key, %error, "outbox dispatcher failed to dead-letter row");
}
return;
}
if dispatch_error.is_worker_connection_lost() {
let visible_after = Utc::now();
warn!(
dispatch_key = %row.dispatch_key,
attempt = row.attempt,
next_attempt = attempted,
error = %dispatch_error,
"outbox dispatch lost the worker connection; re-arming for immediate failover"
);
if let Err(error) = self
.store
.retry_outbox_row(&row.dispatch_key, attempted, visible_after)
.await
{
error!(dispatch_key = %row.dispatch_key, %error, "outbox dispatcher failed to re-arm row for failover");
}
return;
}
let backoff = self.config.backoff_for_attempt(row.attempt);
let visible_after = Utc::now() + chrono_duration(backoff);
warn!(
dispatch_key = %row.dispatch_key,
attempt = row.attempt,
next_attempt = attempted,
backoff_ms = backoff.as_millis(),
error = %dispatch_error,
"outbox dispatch failed; scheduling retry with backoff"
);
if let Err(error) = self
.store
.retry_outbox_row(&row.dispatch_key, attempted, visible_after)
.await
{
error!(dispatch_key = %row.dispatch_key, %error, "outbox dispatcher failed to schedule retry");
}
}
}
fn chrono_duration(duration: Duration) -> chrono::Duration {
chrono::Duration::from_std(duration).unwrap_or(chrono::Duration::MAX)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use aion_core::{ContentType, Payload, WorkflowId};
use aion_store::{OutboxRow, OutboxStatus, OutboxStore};
use aion_store_libsql::LibSqlStore;
use async_trait::async_trait;
use chrono::Utc;
use super::{OutboxDispatcher, OutboxDispatcherConfig, OutboxRowDispatch, ServerError};
fn config() -> OutboxDispatcherConfig {
OutboxDispatcherConfig {
poll_interval: Duration::from_millis(10),
batch_size: 16,
max_attempts: 3,
backoff_base: Duration::from_millis(100),
backoff_multiplier: 2,
backoff_max: Duration::from_secs(60),
}
}
fn unique_temp_path(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
std::env::temp_dir().join(format!(
"aion-server-outbox-dispatcher-{name}-{}-{nanos}.db",
std::process::id()
))
}
async fn open_store(name: &str) -> Result<Arc<LibSqlStore>, ServerError> {
LibSqlStore::open(unique_temp_path(name))
.await
.map(Arc::new)
.map_err(ServerError::from)
}
fn pending_row(workflow_id: &WorkflowId, ordinal: u64) -> OutboxRow {
OutboxRow::pending(
workflow_id.clone(),
ordinal,
String::from("charge"),
Payload::new(ContentType::Json, b"{}".to_vec()),
Utc::now(),
)
}
struct RecordingDispatch {
succeed: bool,
dispatched: Mutex<Vec<OutboxRow>>,
}
impl RecordingDispatch {
fn new(succeed: bool) -> Self {
Self {
succeed,
dispatched: Mutex::new(Vec::new()),
}
}
fn count(&self) -> Result<usize, ServerError> {
Ok(self
.dispatched
.lock()
.map_err(|_| ServerError::lock_poisoned("recording dispatch"))?
.len())
}
}
#[async_trait]
impl OutboxRowDispatch for RecordingDispatch {
async fn dispatch(&self, row: &OutboxRow) -> Result<(), ServerError> {
self.dispatched
.lock()
.map_err(|_| ServerError::lock_poisoned("recording dispatch"))?
.push(row.clone());
if self.succeed {
Ok(())
} else {
Err(ServerError::worker_dispatch(
"default",
"charge",
"no worker in test",
))
}
}
}
#[tokio::test]
async fn sweep_dispatches_claimed_rows_and_marks_them_done()
-> Result<(), Box<dyn std::error::Error>> {
let store = open_store("done").await?;
let workflow_id = WorkflowId::new_v4();
let row_a = pending_row(&workflow_id, 0);
let row_b = pending_row(&workflow_id, 1);
store
.append_outbox_batch(&[row_a.clone(), row_b.clone()])
.await?;
let dispatch = Arc::new(RecordingDispatch::new(true));
let dispatcher = OutboxDispatcher::new(store.clone(), dispatch.clone(), config());
dispatcher.sweep_once().await;
assert_eq!(dispatch.count()?, 2, "both pending rows are dispatched");
assert_eq!(
store
.outbox_row_state(&row_a.dispatch_key)
.await?
.map(|s| s.status),
Some(OutboxStatus::Done)
);
assert_eq!(
store
.outbox_row_state(&row_b.dispatch_key)
.await?
.map(|s| s.status),
Some(OutboxStatus::Done)
);
assert!(store.claim_outbox_rows(10).await?.is_empty());
Ok(())
}
#[tokio::test]
async fn failed_dispatch_retries_with_backoff_and_bumps_attempt()
-> Result<(), Box<dyn std::error::Error>> {
let store = open_store("retry").await?;
let workflow_id = WorkflowId::new_v4();
let row = pending_row(&workflow_id, 0);
store
.append_outbox_batch(std::slice::from_ref(&row))
.await?;
let before = Utc::now();
let dispatch = Arc::new(RecordingDispatch::new(false));
let dispatcher = OutboxDispatcher::new(store.clone(), dispatch, config());
dispatcher.sweep_once().await;
let state = store
.outbox_row_state(&row.dispatch_key)
.await?
.ok_or("retried row must still exist")?;
assert_eq!(state.status, OutboxStatus::Pending);
assert_eq!(state.attempt, 1);
assert!(
state.visible_after >= before + chrono::Duration::milliseconds(100),
"visible_after must advance by at least the base backoff"
);
assert!(store.claim_outbox_rows(10).await?.is_empty());
Ok(())
}
struct ConnectionLostDispatch;
#[async_trait]
impl OutboxRowDispatch for ConnectionLostDispatch {
async fn dispatch(&self, _row: &OutboxRow) -> Result<(), ServerError> {
Err(ServerError::worker_connection_lost(
"liminal-push",
"worker connection closed before reply",
))
}
}
#[tokio::test]
async fn connection_lost_rearms_immediately_and_consumes_attempt()
-> Result<(), Box<dyn std::error::Error>> {
let store = open_store("conn-lost").await?;
let workflow_id = WorkflowId::new_v4();
let row = pending_row(&workflow_id, 0);
store
.append_outbox_batch(std::slice::from_ref(&row))
.await?;
let before = Utc::now();
let dispatcher =
OutboxDispatcher::new(store.clone(), Arc::new(ConnectionLostDispatch), config());
dispatcher.sweep_once().await;
let after = Utc::now();
let state = store
.outbox_row_state(&row.dispatch_key)
.await?
.ok_or("re-armed row must still exist")?;
assert_eq!(state.status, OutboxStatus::Pending);
assert_eq!(state.attempt, 1, "the failover still consumes one attempt");
assert!(
state.visible_after >= before && state.visible_after <= after,
"visible_after must be re-armed to now (immediate re-claim), not backed off"
);
assert!(
state.visible_after < before + chrono::Duration::milliseconds(100),
"immediate re-arm must not apply the base backoff fence"
);
assert_eq!(
store.claim_outbox_rows(10).await?.len(),
1,
"the re-armed row is immediately claimable for failover"
);
Ok(())
}
#[tokio::test]
async fn connection_lost_dead_letters_after_max_attempts()
-> Result<(), Box<dyn std::error::Error>> {
let store = open_store("conn-lost-dead").await?;
let workflow_id = WorkflowId::new_v4();
let mut row = pending_row(&workflow_id, 0);
row.attempt = config().max_attempts - 1;
store
.append_outbox_batch(std::slice::from_ref(&row))
.await?;
let dispatcher =
OutboxDispatcher::new(store.clone(), Arc::new(ConnectionLostDispatch), config());
dispatcher.sweep_once().await;
assert_eq!(
store
.outbox_row_state(&row.dispatch_key)
.await?
.map(|s| s.status),
Some(OutboxStatus::Failed),
"connection-lost churn is bounded by max_attempts and dead-letters"
);
assert!(store.claim_outbox_rows(10).await?.is_empty());
Ok(())
}
#[tokio::test]
async fn dispatch_fails_row_after_max_attempts() -> Result<(), Box<dyn std::error::Error>> {
let store = open_store("fail").await?;
let workflow_id = WorkflowId::new_v4();
let mut row = pending_row(&workflow_id, 0);
row.attempt = config().max_attempts - 1;
store
.append_outbox_batch(std::slice::from_ref(&row))
.await?;
let dispatch = Arc::new(RecordingDispatch::new(false));
let dispatcher = OutboxDispatcher::new(store.clone(), dispatch, config());
dispatcher.sweep_once().await;
assert_eq!(
store
.outbox_row_state(&row.dispatch_key)
.await?
.map(|s| s.status),
Some(OutboxStatus::Failed)
);
assert!(store.claim_outbox_rows(10).await?.is_empty());
Ok(())
}
#[tokio::test]
async fn mark_done_failure_leaves_row_claimed_for_later_rearm()
-> Result<(), Box<dyn std::error::Error>> {
use aion_store::{ClaimScope, StoreError};
use chrono::{DateTime, Utc};
use std::sync::atomic::{AtomicBool, Ordering};
struct CompleteFailsStore {
row: OutboxRow,
claimed: AtomicBool,
completed: AtomicBool,
other_terminal: AtomicBool,
}
#[async_trait]
impl OutboxStore for CompleteFailsStore {
async fn append_outbox_batch(&self, _rows: &[OutboxRow]) -> Result<(), StoreError> {
Ok(())
}
async fn claim_outbox_rows(&self, _limit: u32) -> Result<Vec<OutboxRow>, StoreError> {
if self
.claimed
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
{
Ok(vec![self.row.clone()])
} else {
Ok(Vec::new())
}
}
async fn claim_outbox_rows_scoped(
&self,
_scope: &ClaimScope,
_limit: u32,
) -> Result<Vec<OutboxRow>, StoreError> {
Ok(Vec::new())
}
async fn rearm_stale_claimed_outbox_rows(
&self,
_older_than: DateTime<Utc>,
_visible_after: DateTime<Utc>,
_limit: u32,
) -> Result<Vec<OutboxRow>, StoreError> {
Ok(Vec::new())
}
async fn complete_outbox_row(&self, _dispatch_key: &str) -> Result<(), StoreError> {
self.completed.store(true, Ordering::SeqCst);
Err(StoreError::Backend("mark-done write failed".to_owned()))
}
async fn retry_outbox_row(
&self,
_dispatch_key: &str,
_next_attempt: u32,
_visible_after: DateTime<Utc>,
) -> Result<(), StoreError> {
self.other_terminal.store(true, Ordering::SeqCst);
Ok(())
}
async fn fail_outbox_row(&self, _dispatch_key: &str) -> Result<(), StoreError> {
self.other_terminal.store(true, Ordering::SeqCst);
Ok(())
}
}
let workflow_id = WorkflowId::new_v4();
let store = Arc::new(CompleteFailsStore {
row: pending_row(&workflow_id, 0),
claimed: AtomicBool::new(false),
completed: AtomicBool::new(false),
other_terminal: AtomicBool::new(false),
});
let dispatch = Arc::new(RecordingDispatch::new(true));
let dispatcher = OutboxDispatcher::new(store.clone(), dispatch.clone(), config());
dispatcher.sweep_once().await;
assert_eq!(dispatch.count()?, 1, "the row was dispatched exactly once");
assert!(
store.completed.load(Ordering::SeqCst),
"mark_done was attempted (and failed) after the successful dispatch"
);
assert!(
!store.other_terminal.load(Ordering::SeqCst),
"a mark_done failure must not retry or dead-letter the row (it stays Claimed)"
);
Ok(())
}
async fn wait_for_done(
store: &LibSqlStore,
dispatch_key: &str,
deadline: std::time::Instant,
) -> Result<bool, ServerError> {
loop {
let done = store
.outbox_row_state(dispatch_key)
.await?
.map(|s| s.status)
== Some(OutboxStatus::Done);
if done {
return Ok(true);
}
if std::time::Instant::now() > deadline {
return Ok(false);
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
#[tokio::test]
async fn wake_dispatches_staged_row_well_under_poll_interval()
-> Result<(), Box<dyn std::error::Error>> {
let store = open_store("wake-fast").await?;
let workflow_id = WorkflowId::new_v4();
let row = pending_row(&workflow_id, 0);
store
.append_outbox_batch(std::slice::from_ref(&row))
.await?;
let mut slow_poll = config();
slow_poll.poll_interval = Duration::from_secs(10);
let wake = Arc::new(tokio::sync::Notify::new());
let dispatch = Arc::new(RecordingDispatch::new(true));
let dispatcher = OutboxDispatcher::new(store.clone(), dispatch.clone(), slow_poll)
.with_wake(Arc::clone(&wake));
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let handle = tokio::spawn(dispatcher.run(shutdown_rx));
wake.notify_one();
let reached = wait_for_done(
store.as_ref(),
&row.dispatch_key,
std::time::Instant::now() + Duration::from_secs(1),
)
.await?;
assert!(
reached,
"the wake must dispatch the staged row within 1s, far under the 10s poll"
);
assert_eq!(dispatch.count()?, 1);
shutdown_tx.send(true)?;
tokio::time::timeout(Duration::from_secs(5), handle)
.await
.map_err(|_| "outbox dispatcher did not stop after shutdown")??;
Ok(())
}
#[tokio::test]
async fn poll_dispatches_staged_row_when_wake_never_fires()
-> Result<(), Box<dyn std::error::Error>> {
let store = open_store("wake-absent").await?;
let workflow_id = WorkflowId::new_v4();
let row = pending_row(&workflow_id, 0);
store
.append_outbox_batch(std::slice::from_ref(&row))
.await?;
let wake = Arc::new(tokio::sync::Notify::new());
let dispatch = Arc::new(RecordingDispatch::new(true));
let dispatcher = OutboxDispatcher::new(store.clone(), dispatch.clone(), config())
.with_wake(Arc::clone(&wake));
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let handle = tokio::spawn(dispatcher.run(shutdown_rx));
let reached = wait_for_done(
store.as_ref(),
&row.dispatch_key,
std::time::Instant::now() + Duration::from_secs(5),
)
.await?;
assert!(
reached,
"the poll must dispatch the staged row even though the wake never fired"
);
assert_eq!(dispatch.count()?, 1);
shutdown_tx.send(true)?;
tokio::time::timeout(Duration::from_secs(5), handle)
.await
.map_err(|_| "outbox dispatcher did not stop after shutdown")??;
Ok(())
}
#[tokio::test]
async fn run_loop_drains_pending_then_stops_on_shutdown()
-> Result<(), Box<dyn std::error::Error>> {
let store = open_store("run-loop").await?;
let workflow_id = WorkflowId::new_v4();
let row = pending_row(&workflow_id, 0);
store
.append_outbox_batch(std::slice::from_ref(&row))
.await?;
let dispatch = Arc::new(RecordingDispatch::new(true));
let dispatcher = OutboxDispatcher::new(store.clone(), dispatch.clone(), config());
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let handle = tokio::spawn(dispatcher.run(shutdown_rx));
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let done = store
.outbox_row_state(&row.dispatch_key)
.await?
.map(|s| s.status)
== Some(OutboxStatus::Done);
if done {
break;
}
if std::time::Instant::now() > deadline {
return Err("outbox dispatcher loop did not mark the row done".into());
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(dispatch.count()?, 1);
shutdown_tx.send(true)?;
tokio::time::timeout(Duration::from_secs(5), handle)
.await
.map_err(|_| "outbox dispatcher did not stop after shutdown")??;
Ok(())
}
#[test]
fn backoff_grows_geometrically_and_clamps_to_max() {
let config = config();
assert_eq!(config.backoff_for_attempt(0), Duration::from_millis(100));
assert_eq!(config.backoff_for_attempt(1), Duration::from_millis(200));
assert_eq!(config.backoff_for_attempt(2), Duration::from_millis(400));
assert_eq!(config.backoff_for_attempt(1000), config.backoff_max);
}
#[tokio::test]
async fn worker_dispatch_routes_by_row_namespace_not_server_default()
-> Result<(), Box<dyn std::error::Error>> {
use crate::worker::dispatch::ActivityDispatcher;
use crate::worker::registry::{ConnectedWorkerRegistry, WorkerMessage};
use aion_store::OutboxRow;
let registry = ConnectedWorkerRegistry::default();
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
let activity_types = [String::from("charge")];
let _registration = registry.register("remote", activity_types.iter(), tx)?;
let dispatch = super::WorkerOutboxDispatch::new(ActivityDispatcher::new(registry.clone()));
let workflow_id = WorkflowId::new_v4();
let remote_row = OutboxRow::pending(
workflow_id.clone(),
0,
String::from("charge"),
Payload::new(ContentType::Json, b"{}".to_vec()),
Utc::now(),
)
.with_namespace("remote")
.with_task_queue("default");
OutboxRowDispatch::dispatch(&dispatch, &remote_row).await?;
let message = rx.recv().await.ok_or("expected pushed activity task")?;
assert!(
matches!(message, WorkerMessage::ActivityTask(_)),
"the remote-namespace worker must receive the activity task for a remote row"
);
let default_row = remote_row.clone().with_namespace("default");
let blocked = tokio::time::timeout(
Duration::from_millis(200),
OutboxRowDispatch::dispatch(&dispatch, &default_row),
)
.await;
assert!(
blocked.is_err(),
"a default-namespace row must not be served by a remote-namespace worker"
);
Ok(())
}
#[test]
fn to_scheduled_sources_node_affinity_from_row() {
let workflow_id = WorkflowId::new_v4();
let pinned = OutboxRow::pending(
workflow_id.clone(),
0,
String::from("charge"),
Payload::new(ContentType::Json, b"{}".to_vec()),
Utc::now(),
)
.with_node(Some(String::from("box-7")));
let scheduled = super::WorkerOutboxDispatch::to_scheduled(&pinned);
assert_eq!(scheduled.node.as_deref(), Some("box-7"));
let unpinned = pending_row(&workflow_id, 1);
let scheduled = super::WorkerOutboxDispatch::to_scheduled(&unpinned);
assert_eq!(scheduled.node, None);
}
#[tokio::test]
async fn claim_marks_row_claimed_then_sweep_advances_to_done()
-> Result<(), Box<dyn std::error::Error>> {
let store = open_store("claimed").await?;
let workflow_id = WorkflowId::new_v4();
let row = pending_row(&workflow_id, 0);
store
.append_outbox_batch(std::slice::from_ref(&row))
.await?;
let claimed = store.claim_outbox_rows(10).await?;
assert_eq!(claimed.len(), 1);
assert_eq!(claimed[0].status, OutboxStatus::Claimed);
Ok(())
}
}