use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use dbos::sysdb::SystemDatabase;
use dbos::sysdb::postgres::{PostgresSystemDatabase, Settings};
use dbos::sysdb::types::{NewWorkflow, Outcome, Timestamp, WorkflowIdReusePolicy, WorkflowStatus};
use dbos::{
Config, DBOS, Error, QueueConflict, QueueOptions, QueueRequest, RunOptions, StartOptions,
Timeout,
};
use dbos_test_support::{
Backend, TestDatabase, owner_token, raw_database, skewed_database, test_database,
};
const APP_VERSION: &str = "1.0.0";
fn config(app_name: &str, db: &TestDatabase) -> Config {
Config {
migrate: false,
app_version: Some(APP_VERSION.to_owned()),
..Config::new(app_name, db.url())
}
}
async fn reader(db: &TestDatabase) -> PostgresSystemDatabase {
PostgresSystemDatabase::from_pool(db.pool().await, &Settings::default())
}
#[tokio::test]
async fn a_workflow_past_its_deadline_is_cancelled() {
let db = test_database().await;
let dbos = DBOS::new(config("deadline-app", &db));
let workflow = dbos
.register_workflow("runs_forever", |()| async move {
tokio::time::sleep(Duration::from_secs(30)).await;
Ok::<u32, dbos::Error>(1)
})
.unwrap();
dbos.launch().await.expect("launch failed");
let id = "too-slow";
let error = workflow
.run_with(
(),
RunOptions {
workflow_id: Some(id),
timeout: Timeout::Explicit(Duration::from_millis(100)),
..Default::default()
},
)
.await
.expect_err("the workflow succeeded");
assert!(
matches!(error, Error::WorkflowCancelled { .. }),
"expected a cancellation, got {error:?}"
);
let reader = reader(&db).await;
let row = reader
.get_workflow(id)
.await
.expect("read failed")
.expect("the row is missing");
assert_eq!(row.status, WorkflowStatus::Cancelled);
dbos.shutdown().await;
}
#[tokio::test]
async fn a_workflow_within_its_deadline_is_unaffected() {
let db = test_database().await;
let dbos = DBOS::new(config("within-deadline-app", &db));
let workflow = dbos
.register_workflow("quick", |()| async move { Ok::<u32, dbos::Error>(7) })
.unwrap();
dbos.launch().await.expect("launch failed");
let id = "fast-enough";
let value = workflow
.run_with(
(),
RunOptions {
workflow_id: Some(id),
timeout: Timeout::Explicit(Duration::from_secs(30)),
..Default::default()
},
)
.await
.expect("the workflow failed");
assert_eq!(value, 7);
let reader = reader(&db).await;
let row = reader
.get_workflow(id)
.await
.expect("read failed")
.expect("the row is missing");
assert_eq!(row.status, WorkflowStatus::Success);
dbos.shutdown().await;
}
#[tokio::test]
async fn a_recovered_workflow_keeps_the_deadline_it_already_had() {
let db = test_database().await;
let id = "keeps-its-deadline";
{
let dbos = DBOS::new(config("recover-deadline-app", &db));
let workflow = dbos
.register_workflow("slow", |()| async move {
tokio::time::sleep(Duration::from_secs(30)).await;
Ok::<u32, dbos::Error>(1)
})
.unwrap();
dbos.launch().await.expect("launch failed");
workflow
.start_with(
(),
StartOptions {
workflow_id: Some(id),
timeout: Timeout::Explicit(Duration::from_millis(400)),
..StartOptions::default()
},
)
.await
.expect("start failed");
dbos.shutdown().await;
}
let reader = reader(&db).await;
let row = reader
.get_workflow(id)
.await
.expect("read failed")
.expect("the row is missing");
assert_eq!(
row.status,
WorkflowStatus::Pending,
"shutdown left it PENDING"
);
let stored = row.deadline.expect("the deadline is stored on the row");
tokio::time::sleep(Duration::from_millis(500)).await;
let dbos = DBOS::new(config("recover-deadline-app", &db));
dbos.register_workflow("slow", |()| async move {
tokio::time::sleep(Duration::from_secs(30)).await;
Ok::<u32, dbos::Error>(1)
})
.unwrap();
dbos.launch().await.expect("launch failed");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let row = reader
.get_workflow(id)
.await
.expect("read failed")
.expect("the row is missing");
if row.status == WorkflowStatus::Cancelled {
assert_eq!(
row.deadline,
Some(stored),
"recovery kept the deadline it already had"
);
break;
}
assert!(
std::time::Instant::now() < deadline,
"the recovered workflow was not cancelled; status {:?}",
row.status
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
dbos.shutdown().await;
}
#[tokio::test]
async fn shutdown_does_not_durably_cancel_a_workflow_that_has_a_deadline() {
let db = test_database().await;
let dbos = DBOS::new(config("shutdown-deadline-app", &db));
let started = Arc::new(tokio::sync::Notify::new());
let dbos_started = Arc::clone(&started);
let workflow = dbos
.register_workflow("slow", move |()| {
let started = Arc::clone(&dbos_started);
async move {
started.notify_one();
tokio::time::sleep(Duration::from_secs(30)).await;
Ok::<u32, dbos::Error>(1)
}
})
.unwrap();
dbos.launch().await.expect("launch failed");
let id = "interrupted-not-cancelled";
workflow
.start_with(
(),
StartOptions {
workflow_id: Some(id),
timeout: Timeout::Explicit(Duration::from_secs(300)),
..StartOptions::default()
},
)
.await
.expect("start failed");
started.notified().await;
dbos.shutdown().await;
let reader = reader(&db).await;
let row = reader
.get_workflow(id)
.await
.expect("read failed")
.expect("the row is missing");
assert_eq!(
row.status,
WorkflowStatus::Pending,
"shutdown durably cancelled a workflow it should have left for recovery"
);
}
#[tokio::test]
async fn a_deadline_that_loses_to_a_recorded_outcome_reports_that_outcome() {
let db = test_database().await;
let dbos = DBOS::new(config("deadline-race-app", &db));
let workflow = dbos
.register_workflow("runs_forever", |()| async move {
tokio::time::sleep(Duration::from_secs(30)).await;
Ok::<u32, dbos::Error>(1)
})
.unwrap();
dbos.launch().await.expect("launch failed");
let id = "finished-elsewhere";
let handle = workflow
.start_with(
(),
StartOptions {
workflow_id: Some(id),
timeout: Timeout::Explicit(Duration::from_millis(600)),
..StartOptions::default()
},
)
.await
.expect("start failed");
tokio::time::sleep(Duration::from_millis(150)).await;
let reader = reader(&db).await;
reader
.record_workflow_outcome(
id,
Outcome::Output(Some("7")),
&owner_token(&reader, id).await,
)
.await
.expect("the rival could not record its outcome");
assert_eq!(
handle
.result()
.await
.expect("the deadline reported a cancellation over a recorded result"),
7,
"the caller must read what the row holds"
);
let row = reader
.get_workflow(id)
.await
.expect("read failed")
.expect("the row is missing");
assert_eq!(
row.status,
WorkflowStatus::Success,
"the deadline overwrote a terminal row"
);
dbos.shutdown().await;
}
fn sweeping_config(app_name: &str, db: &TestDatabase) -> Config {
dbos::__private::set_workflow_timeout_sweep_interval(Duration::from_millis(100));
config(app_name, db)
}
async fn writer(db: &TestDatabase, app_name: &'static str) -> PostgresSystemDatabase {
PostgresSystemDatabase::from_pool(
db.pool().await,
&Settings {
application_name: Some(app_name),
..Settings::default()
},
)
}
async fn insert_expired(sys: &PostgresSystemDatabase, id: &str, queue: Option<&str>) {
sys.init_workflow(
&NewWorkflow {
name: Some("never-registered"),
executor_id: Some("dead-executor"),
queue_name: queue,
deadline: Some(Timestamp::from_epoch_ms(1_000)),
..NewWorkflow::new(id)
},
None,
WorkflowIdReusePolicy::ReturnExisting,
)
.await
.expect("insert failed");
}
async fn wait_for_status(reader: &PostgresSystemDatabase, id: &str, status: WorkflowStatus) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let row = reader.get_workflow(id).await.unwrap().expect("row missing");
if row.status == status {
return;
}
assert!(
tokio::time::Instant::now() < deadline,
"{id} is still {} rather than {status}",
row.status
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[tokio::test]
async fn the_sweep_cancels_expired_workflows_nobody_is_running() {
let db = test_database().await;
let dbos = DBOS::new(sweeping_config("sweep-app", &db));
dbos.launch().await.expect("launch failed");
let ours = writer(&db, "sweep-app").await;
insert_expired(&ours, "queued-nowhere", Some("unpolled-queue")).await;
insert_expired(&ours, "orphaned", None).await;
insert_expired(&writer(&db, "another-app").await, "not-ours", None).await;
let reader = reader(&db).await;
for id in ["queued-nowhere", "orphaned"] {
wait_for_status(&reader, id, WorkflowStatus::Cancelled).await;
}
let queued = reader
.get_workflow("queued-nowhere")
.await
.unwrap()
.unwrap();
assert_eq!(
queued.queue_name, None,
"a cancelled workflow leaves its queue"
);
assert_eq!(
reader
.get_workflow("not-ours")
.await
.unwrap()
.unwrap()
.status,
WorkflowStatus::Pending,
"another application's workflow is its own sweep's to cancel"
);
dbos.shutdown().await;
}
#[tokio::test]
async fn a_failed_sweep_pass_does_not_stop_the_sweep() {
let db = raw_database().await;
if db.backend() == Backend::Cockroach {
return;
}
let dbos = DBOS::new(Config {
migrate: true,
..sweeping_config("sweep-failure-app", &db)
});
dbos.launch().await.expect("launch failed");
let pool = db.pool().await;
let rename = |from: &'static str, to: &'static str| {
let pool = pool.clone();
async move {
sqlx::query(sqlx::AssertSqlSafe(format!(
r#"ALTER TABLE "dbos"."workflow_status" RENAME COLUMN {from} TO {to}"#
)))
.execute(&pool)
.await
.expect("rename failed");
}
};
rename("workflow_deadline_epoch_ms", "deadline_hidden").await;
tokio::time::sleep(Duration::from_millis(500)).await;
rename("deadline_hidden", "workflow_deadline_epoch_ms").await;
insert_expired(
&writer(&db, "sweep-failure-app").await,
"after-the-failure",
None,
)
.await;
wait_for_status(
&reader(&db).await,
"after-the-failure",
WorkflowStatus::Cancelled,
)
.await;
dbos.shutdown().await;
}
#[tokio::test]
async fn the_sweep_stops_at_shutdown_and_restarts_on_relaunch() {
let db = test_database().await;
let dbos = DBOS::new(sweeping_config("sweep-relaunch-app", &db));
dbos.launch().await.expect("launch failed");
dbos.shutdown().await;
insert_expired(&writer(&db, "sweep-relaunch-app").await, "while-down", None).await;
tokio::time::sleep(Duration::from_millis(500)).await;
let reader = reader(&db).await;
assert_eq!(
reader
.get_workflow("while-down")
.await
.unwrap()
.unwrap()
.status,
WorkflowStatus::Pending,
"nothing sweeps once the executor is shut down"
);
dbos.launch().await.expect("relaunch failed");
wait_for_status(&reader, "while-down", WorkflowStatus::Cancelled).await;
dbos.shutdown().await;
}
#[tokio::test]
async fn a_timed_out_run_cancels_its_own_row_where_the_sweep_does_not_reach() {
let db = test_database().await;
let dbos = DBOS::new(sweeping_config("deadline-scope-app", &db));
let started = Arc::new(tokio::sync::Notify::new());
let dbos_started = Arc::clone(&started);
let workflow = dbos
.register_workflow("runs_forever", move |()| {
let started = Arc::clone(&dbos_started);
async move {
started.notify_one();
tokio::time::sleep(Duration::from_secs(30)).await;
Ok::<u32, dbos::Error>(1)
}
})
.unwrap();
dbos.launch().await.expect("launch failed");
let id = "out-of-scope";
let handle = workflow
.start_with(
(),
StartOptions {
workflow_id: Some(id),
timeout: Timeout::Explicit(Duration::from_millis(500)),
..StartOptions::default()
},
)
.await
.expect("start failed");
started.notified().await;
sqlx::query(
r#"UPDATE "dbos"."workflow_status" SET application_name = 'another-app'
WHERE workflow_uuid = $1"#,
)
.bind(id)
.execute(&db.pool().await)
.await
.unwrap();
let error = tokio::time::timeout(Duration::from_secs(10), handle.result())
.await
.expect("the timed-out run never ended")
.expect_err("the workflow succeeded");
assert!(
matches!(error, Error::WorkflowCancelled { .. }),
"expected a cancellation, got {error:?}"
);
let row = reader(&db).await.get_workflow(id).await.unwrap().unwrap();
assert_eq!(row.status, WorkflowStatus::Cancelled);
assert_eq!(
row.app_name.as_deref(),
Some("another-app"),
"the row stayed outside this application's sweep"
);
dbos.shutdown().await;
}
#[tokio::test]
async fn starting_a_timed_out_workflow_again_does_not_rerun_it() {
let db = test_database().await;
let dbos = DBOS::new(sweeping_config("deadline-rerun-app", &db));
let runs = Arc::new(std::sync::atomic::AtomicU32::new(0));
let dbos_runs = Arc::clone(&runs);
let workflow = dbos
.register_workflow("counted", move |()| {
let runs = Arc::clone(&dbos_runs);
async move {
runs.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
tokio::time::sleep(Duration::from_secs(30)).await;
Ok::<u32, dbos::Error>(1)
}
})
.unwrap();
dbos.launch().await.expect("launch failed");
let rerun = |id: &'static str| {
let workflow = workflow.clone();
async move {
workflow
.run_with(
(),
RunOptions {
workflow_id: Some(id),
..Default::default()
},
)
.await
.expect_err("the workflow succeeded")
}
};
let error = workflow
.run_with(
(),
RunOptions {
workflow_id: Some("self-timed-out"),
timeout: Timeout::Explicit(Duration::from_millis(200)),
..Default::default()
},
)
.await
.expect_err("the workflow succeeded");
assert!(
matches!(error, Error::WorkflowCancelled { .. }),
"{error:?}"
);
assert_eq!(runs.load(std::sync::atomic::Ordering::SeqCst), 1);
let error = rerun("self-timed-out").await;
assert!(
matches!(error, Error::WorkflowCancelled { .. }),
"{error:?}"
);
let reader = reader(&db).await;
writer(&db, "deadline-rerun-app")
.await
.init_workflow(
&NewWorkflow {
name: Some("counted"),
input: Some("null"),
serialization: Some("rust_serde"),
deadline: Some(Timestamp::from_epoch_ms(1_000)),
..NewWorkflow::new("swept")
},
None,
WorkflowIdReusePolicy::ReturnExisting,
)
.await
.expect("insert failed");
wait_for_status(&reader, "swept", WorkflowStatus::Cancelled).await;
let error = rerun("swept").await;
assert!(
matches!(error, Error::WorkflowCancelled { .. }),
"{error:?}"
);
assert_eq!(
runs.load(std::sync::atomic::Ordering::SeqCst),
1,
"neither start ran the body again"
);
dbos.shutdown().await;
}
type Lifetimes = Arc<Mutex<Vec<(String, Duration)>>>;
struct Lifetime {
label: String,
started: Instant,
lifetimes: Lifetimes,
}
impl Drop for Lifetime {
fn drop(&mut self) {
self.lifetimes
.lock()
.unwrap()
.push((self.label.clone(), self.started.elapsed()));
}
}
async fn timeouts_hold_with_the_database_clock_off_by(seconds: i64) {
const BUDGET: Duration = Duration::from_millis(600);
let Some(db) = skewed_database(seconds).await else {
return;
};
let dbos = DBOS::new(Config {
migrate: true,
..sweeping_config("deadline-skew-app", &db)
});
let lifetimes = Lifetimes::default();
let dbos_lifetimes = Arc::clone(&lifetimes);
let workflow = dbos
.register_workflow("bounded", move |label: String| {
let lifetimes = Arc::clone(&dbos_lifetimes);
async move {
let _lifetime = Lifetime {
label,
started: Instant::now(),
lifetimes,
};
tokio::time::sleep(Duration::from_secs(120)).await;
Ok::<u32, dbos::Error>(1)
}
})
.unwrap();
dbos.launch().await.expect("launch failed");
dbos.register_queue(
"skew-queue",
QueueOptions {
polling_interval: Duration::from_millis(100),
..QueueOptions::default()
},
QueueConflict::UpdateIfLatestVersion,
)
.await
.expect("queue registration failed");
for (label, queue) in [
("direct", None),
("queued", Some(QueueRequest::new("skew-queue"))),
] {
let direct = queue.is_none();
let began = Instant::now();
let handle = workflow
.start_with(
label.to_owned(),
StartOptions {
workflow_id: Some(label),
timeout: Timeout::Explicit(BUDGET),
queue,
..StartOptions::default()
},
)
.await
.expect("start failed");
let error = tokio::time::timeout(Duration::from_secs(10), handle.result())
.await
.unwrap_or_else(|_| panic!("{label}: still running 10s after it started"))
.expect_err("the workflow succeeded");
assert!(
matches!(error, Error::WorkflowCancelled { .. }),
"{label}: expected a cancellation, got {error:?}"
);
let ended = began.elapsed();
let lived = loop {
let lived = lifetimes
.lock()
.unwrap()
.iter()
.find(|(of, _)| of == label)
.map(|(_, lived)| *lived);
if let Some(lived) = lived {
break lived;
}
assert!(
began.elapsed() < ended + Duration::from_secs(1),
"{label}: the body was still running a second after the cancel was reported"
);
tokio::time::sleep(Duration::from_millis(20)).await;
};
assert!(
lived >= BUDGET / 2,
"{label}: the body was stopped after {lived:?}, inside its budget"
);
assert!(
ended < Duration::from_secs(4),
"{label}: cancelled after {ended:?}, long past its budget"
);
let row = reader(&db)
.await
.get_workflow(label)
.await
.unwrap()
.unwrap();
assert_eq!(row.status, WorkflowStatus::Cancelled, "{label}");
let deadline = row.deadline.expect("the deadline is on the row");
if direct {
assert_eq!(
deadline.as_epoch_ms(),
row.created_at.as_epoch_ms() + BUDGET.as_millis() as i64,
"{label}: the deadline is the database's clock at the insert plus the budget"
);
}
let completed = row.completed_at.expect("a cancel stamps completed_at");
assert!(
completed >= deadline,
"{label}: cancelled at {completed:?}, before its deadline {deadline:?}"
);
}
dbos.shutdown().await;
}
#[tokio::test]
async fn a_timeout_holds_when_the_database_clock_runs_ahead() {
timeouts_hold_with_the_database_clock_off_by(60).await;
}
#[tokio::test]
async fn a_timeout_holds_when_the_database_clock_runs_behind() {
timeouts_hold_with_the_database_clock_off_by(-60).await;
}
#[tokio::test]
async fn a_timeout_the_row_cannot_hold_is_refused() {
let db = test_database().await;
let dbos = DBOS::new(config("unstorable-timeout-app", &db));
let workflow = dbos
.register_workflow("bounded", |()| async move { Ok::<u32, Error>(1) })
.unwrap();
dbos.launch().await.expect("launch failed");
let id = "never-started";
let refused = workflow
.start_with(
(),
StartOptions {
workflow_id: Some(id),
timeout: Timeout::Explicit(Duration::MAX),
..StartOptions::default()
},
)
.await;
assert!(
matches!(&refused, Err(Error::InvalidArgument { operation, detail })
if operation == "start a workflow" && detail.contains("`timeout`")),
"expected the timeout to be refused, got {:?}",
refused.map(|_| ())
);
assert!(
reader(&db)
.await
.get_workflow(id)
.await
.expect("read failed")
.is_none(),
"a refused start wrote a row"
);
dbos.shutdown().await;
}
#[tokio::test]
async fn a_timeout_near_the_largest_gives_a_deadline_that_never_elapses() {
let db = test_database().await;
let dbos = DBOS::new(config("far-timeout-app", &db));
let workflow = dbos
.register_workflow("bounded", |()| async move { Ok::<u32, Error>(7) })
.unwrap();
dbos.launch().await.expect("launch failed");
let id = "bounded-far-out";
let result = workflow
.run_with(
(),
RunOptions {
workflow_id: Some(id),
timeout: Timeout::Explicit(Duration::from_millis(i64::MAX as u64 - 1000)),
..RunOptions::default()
},
)
.await
.expect("a timeout that fits in milliseconds should be accepted");
assert_eq!(result, 7);
let row = reader(&db)
.await
.get_workflow(id)
.await
.expect("read failed")
.expect("the workflow was written");
assert_eq!(row.status, WorkflowStatus::Success);
assert_eq!(row.deadline, Some(Timestamp::from_epoch_ms(i64::MAX)));
dbos.shutdown().await;
}