use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;
use crate::config::Config;
use crate::connection::Connection;
use crate::identity::{Environment, Identity};
use crate::registry::{Registry, Snapshot};
use crate::serialization::Serializer;
use crate::sysdb::SystemDatabase;
use crate::workflow::{Role, Tasks};
use crate::{Error, Result};
pub struct Executor {
conn: Arc<Connection>,
executor_id: String,
app_version: String,
app_name: String,
app_id: String,
runtime: crate::rt::Spawner,
workflows: Snapshot,
listen_queues: Option<Vec<String>>,
producer_tasks: Tasks,
workflow_tasks: Tasks,
monitor_tasks: Tasks,
stopping: CancellationToken,
}
impl Executor {
async fn start(config: &Config, workflows: Snapshot) -> Result<(Self, Vec<String>)> {
config.validate()?;
if workflows.is_empty() {
tracing::warn!(
"no workflows are registered: this executor will recover nothing and dequeue \
nothing. Register before calling `launch`."
);
}
let Identity {
app_name,
app_version,
executor_id,
app_id,
} = crate::identity::resolve(config, &Environment::read())?;
let conn = Connection::for_application(config, &executor_id, &app_name).await?;
let recovered = match prepare(conn.sysdb(), &executor_id, &app_version).await {
Ok(recovered) => recovered,
Err(error) => {
conn.close().await;
return Err(error);
}
};
tracing::info!(app_name, executor_id, app_version, "DBOS launched");
let executor = Self {
conn: Arc::new(conn),
executor_id,
app_version,
app_id,
runtime: crate::rt::Spawner::current(),
workflows,
app_name,
listen_queues: config.listen_queues.clone(),
producer_tasks: Tasks::default(),
workflow_tasks: Tasks::default(),
monitor_tasks: Tasks::default(),
stopping: CancellationToken::new(),
};
Ok((executor, recovered))
}
pub(crate) fn connection(&self) -> &Arc<Connection> {
&self.conn
}
pub(crate) fn sysdb(&self) -> &dyn SystemDatabase {
self.conn.sysdb()
}
pub fn executor_id(&self) -> &str {
&self.executor_id
}
pub fn app_name(&self) -> &str {
&self.app_name
}
pub(crate) fn tasks(&self, role: Role) -> &Tasks {
match role {
Role::Producer => &self.producer_tasks,
Role::Workflow => &self.workflow_tasks,
Role::Monitor => &self.monitor_tasks,
}
}
pub(crate) async fn sleep_unless_stopping(&self, duration: Duration) -> bool {
tokio::select! {
biased;
() = self.stopping.cancelled() => false,
() = crate::rt::sleep(duration) => true,
}
}
pub(crate) fn is_stopping(&self) -> bool {
self.stopping.is_cancelled()
}
pub(crate) fn listen_queues(&self) -> Option<&[String]> {
self.listen_queues.as_deref()
}
pub(crate) fn outcome_poll_interval(&self) -> std::time::Duration {
self.conn.outcome_poll_interval()
}
pub fn app_version(&self) -> &str {
&self.app_version
}
pub fn app_id(&self) -> &str {
&self.app_id
}
pub(crate) fn serializer(&self) -> &Serializer {
self.conn.serializer()
}
pub(crate) fn workflows(&self) -> &Snapshot {
&self.workflows
}
pub(crate) fn runtime(&self) -> &crate::rt::Spawner {
&self.runtime
}
pub(crate) async fn stop(&self, workflow_completion_timeout: Duration) {
self.stopping.cancel();
let draining = !workflow_completion_timeout.is_zero();
if draining {
let deadline = crate::rt::now() + workflow_completion_timeout;
if self.producer_tasks.wait_all_until(deadline).await {
self.drain(deadline).await;
}
}
let producers = self.producer_tasks.abort_all().await;
let abandoned = self.workflow_tasks.abort_all().await;
if draining {
if !producers.is_empty() {
tracing::warn!(
tasks = ?producers,
"the dequeue loop or the scheduler was still mid-pass at the shutdown \
deadline and was cancelled"
);
}
if !abandoned.is_empty() {
tracing::warn!(
workflows = abandoned.len(),
workflow_ids = ?abandoned,
"workflows still running at the shutdown deadline were cancelled; they stay \
PENDING and are recovered from their last checkpoint"
);
}
} else if !abandoned.is_empty() {
tracing::info!(
workflows = abandoned.len(),
workflow_ids = ?abandoned,
"cancelled workflows still running; they stay PENDING"
);
}
}
async fn drain(&self, deadline: Instant) {
loop {
let report_at = (crate::rt::now() + DRAIN_REPORT_INTERVAL).min(deadline);
if self.workflow_tasks.wait_all_until(report_at).await || report_at >= deadline {
return;
}
let running = self.workflow_tasks.running();
tracing::info!(
workflows = running.len(),
workflow_ids = ?running,
"waiting for workflows to finish before shutting down"
);
}
}
pub(crate) async fn close(&self) {
self.monitor_tasks.abort_all().await;
self.conn.close().await;
}
}
const DRAIN_REPORT_INTERVAL: Duration = Duration::from_secs(1);
impl std::fmt::Debug for Executor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Executor")
.field("executor_id", &self.executor_id)
.field("app_version", &self.app_version)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Default)]
pub struct ShutdownOptions {
pub workflow_completion_timeout: Duration,
}
#[derive(Clone)]
pub struct DBOS(Arc<Inner>);
struct Inner {
config: Config,
registry: Registry,
executor: Arc<ExecutorSlot>,
lifecycle: tokio::sync::Mutex<()>,
}
impl Drop for Inner {
fn drop(&mut self) {
if self.executor.read().is_some() {
tracing::warn!(
app_name = self.config.app_name,
"DBOS dropped while still launched: call `shutdown().await` to close the system \
database cleanly. Its connections are closed by the pool's own drop, but any \
workflow still running is left where it stood."
);
}
}
}
impl DBOS {
pub fn new(config: Config) -> Self {
Self(Arc::new(Inner {
config,
registry: Registry::default(),
executor: Arc::default(),
lifecycle: tokio::sync::Mutex::new(()),
}))
}
pub fn config(&self) -> &Config {
&self.0.config
}
pub(crate) fn registry(&self) -> &Registry {
&self.0.registry
}
pub fn is_launched(&self) -> bool {
self.read_executor().is_some()
}
pub async fn launch(&self) -> Result<()> {
let _lifecycle = self.0.lifecycle.lock().await;
if self.read_executor().is_some() {
tracing::warn!("DBOS was already launched");
return Ok(());
}
let workflows = self.0.registry.snapshot();
let (executor, recovered) = match Executor::start(&self.0.config, workflows).await {
Ok(started) => started,
Err(error) => {
self.0.registry.thaw();
return Err(error);
}
};
let executor = Arc::new(executor);
*self.write_executor() = Some(Arc::clone(&executor));
if !recovered.is_empty() {
tracing::debug!(
workflows = recovered.len(),
"recovered workflows are on their queues"
);
}
crate::scheduler::spawn(
Arc::clone(&executor),
self.0.config.scheduler_polling_interval(),
);
crate::timeout_sweep::spawn(Arc::clone(&executor));
crate::dequeue::spawn(executor);
Ok(())
}
pub async fn shutdown(&self) {
self.shutdown_with(ShutdownOptions::default()).await;
}
pub async fn shutdown_with(&self, options: ShutdownOptions) {
let dbos = self.clone();
match crate::rt::spawn(async move { dbos.tear_down(options).await }).await {
Ok(()) => {}
Err(join) if join.is_panic() => std::panic::resume_unwind(join.into_panic()),
Err(_) => {}
}
}
async fn tear_down(&self, options: ShutdownOptions) {
let _lifecycle = self.0.lifecycle.lock().await;
let Some(executor) = self.read_executor() else {
return;
};
executor.stop(options.workflow_completion_timeout).await;
self.write_executor().take();
executor.close().await;
self.0.registry.thaw();
tracing::info!(app_name = self.0.config.app_name, "DBOS shut down");
}
pub fn executor_id(&self) -> Result<String> {
Ok(self.executor("executor_id")?.executor_id().to_owned())
}
pub fn app_version(&self) -> Result<String> {
Ok(self.executor("app_version")?.app_version().to_owned())
}
pub fn app_id(&self) -> Result<String> {
Ok(self.executor("app_id")?.app_id().to_owned())
}
pub(crate) fn executor(&self, operation: &'static str) -> Result<Arc<Executor>> {
self.0.executor.get(operation)
}
pub(crate) fn executor_slot(&self) -> Arc<ExecutorSlot> {
Arc::clone(&self.0.executor)
}
fn read_executor(&self) -> Option<Arc<Executor>> {
self.0.executor.read()
}
fn write_executor(&self) -> std::sync::RwLockWriteGuard<'_, Option<Arc<Executor>>> {
self.0.executor.write()
}
}
#[derive(Default)]
pub(crate) struct ExecutorSlot(RwLock<Option<Arc<Executor>>>);
impl ExecutorSlot {
pub(crate) fn get(&self, operation: &'static str) -> Result<Arc<Executor>> {
self.read().ok_or(Error::NotLaunched {
operation: operation.into(),
})
}
fn read(&self) -> Option<Arc<Executor>> {
self.0
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
fn write(&self) -> std::sync::RwLockWriteGuard<'_, Option<Arc<Executor>>> {
self.0
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
impl std::fmt::Debug for DBOS {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DBOS")
.field("app_name", &self.0.config.app_name)
.field("launched", &self.is_launched())
.finish_non_exhaustive()
}
}
async fn prepare(
sysdb: &dyn SystemDatabase,
executor_id: &str,
app_version: &str,
) -> Result<Vec<String>> {
register_version(sysdb, app_version).await?;
crate::recovery::reenqueue(sysdb, executor_id, app_version).await
}
async fn register_version(sysdb: &dyn SystemDatabase, version: &str) -> Result<()> {
sysdb
.create_application_version(version, None)
.await
.map_err(Error::SystemDatabase)?;
match sysdb
.get_latest_application_version(None)
.await
.map_err(Error::SystemDatabase)?
{
Some(latest) if latest.version_name != version => {
tracing::warn!(
app_version = version,
latest_version = latest.version_name,
"this executor is not running the latest registered application version: it will \
recover and dequeue only work stamped with its own version"
);
}
_ => {}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> Config {
Config {
app_version: Some("1.0.0".to_owned()),
..Config::new("test-app", "postgres://localhost/nothing")
}
}
#[tokio::test]
async fn nothing_is_launched_until_launch() {
let dbos = DBOS::new(config());
assert!(!dbos.is_launched());
let err = dbos.app_version().unwrap_err();
assert!(
matches!(
err,
Error::NotLaunched {
operation: std::borrow::Cow::Borrowed("app_version")
}
),
"{err}"
);
}
#[tokio::test]
async fn not_launched_names_the_operation_that_was_too_early() {
let dbos = DBOS::new(config());
let err = dbos.executor("do_something").unwrap_err();
assert_eq!(
err.to_string(),
"cannot do_something before DBOS is launched"
);
}
#[test]
fn the_lifecycle_futures_are_send() {
fn assert_send<F: Send>(_: F) {}
let dbos = DBOS::new(config());
assert_send(dbos.launch());
assert_send(dbos.shutdown());
}
#[tokio::test]
async fn shutting_down_an_unlaunched_instance_does_nothing() {
let dbos = DBOS::new(config());
dbos.shutdown().await;
assert!(!dbos.is_launched());
}
#[tokio::test]
async fn an_invalid_config_is_refused_before_anything_is_connected() {
let dbos = DBOS::new(Config {
app_version: Some("1.0.0".to_owned()),
..Config::new("No", "postgres://localhost/nothing")
});
let err = dbos.launch().await.unwrap_err();
assert!(matches!(err, Error::Config(_)), "{err}");
assert!(!dbos.is_launched());
}
#[test]
fn registration_does_not_make_the_instance_hold_itself() {
let dbos = DBOS::new(config());
let alive = Arc::downgrade(&dbos.0);
dbos.register_workflow("noop", |()| async { Ok::<_, Error>(()) })
.unwrap();
drop(dbos);
assert_eq!(
alive.strong_count(),
0,
"the instance outlived every handle to it: something in the registry holds it"
);
}
#[test]
fn debug_does_not_print_the_database_url() {
let dbos = DBOS::new(Config::new("test-app", "postgres://user:hunter2@host/db"));
let shown = format!("{dbos:?}");
assert!(!shown.contains("hunter2"), "{shown}");
assert!(shown.contains("test-app"), "{shown}");
}
fn launchable(app_name: &str, db: &dbos_test_support::TestDatabase) -> DBOS {
DBOS::new(Config {
migrate: false,
app_version: Some(format!("{app_name}-1.0.0")),
..Config::new(app_name, db.url())
})
}
async fn shut_down(slot: &ExecutorSlot) {
let executor = slot.write().take().expect("still launched");
executor.stop(Duration::ZERO).await;
executor.close().await;
}
#[tokio::test]
async fn a_workflow_ref_starts_workflows_after_the_instance_is_dropped() {
let db = dbos_test_support::test_database().await;
let dbos = launchable("surviving-ref", &db);
let child = dbos
.register_workflow("child", |n: u32| async move { Ok::<u32, Error>(n * 2) })
.expect("registration failed");
let parent = {
let child = child.clone();
dbos.register_workflow("parent", move |n: u32| {
let child = child.clone();
async move { child.run(n).await }
})
.expect("registration failed")
};
dbos.launch().await.expect("launch failed");
let slot = dbos.executor_slot();
drop(dbos);
assert_eq!(child.run(3).await.expect("the run failed"), 6);
let handle = child.start(4).await.expect("the start failed");
assert_eq!(handle.result().await.expect("the child failed"), 8);
assert_eq!(
parent.run(5).await.expect("the parent failed"),
10,
"a child started from a workflow body, with no handle to the instance anywhere"
);
shut_down(&slot).await;
}
#[tokio::test]
async fn a_dropped_instance_s_workflow_ref_is_still_the_wrong_instance() {
let db = dbos_test_support::test_database().await;
let other = launchable("dropped-other-instance", &db);
let owner = launchable("dropped-owner-instance", &db);
let child = other
.register_workflow("child", |()| async move { Ok::<u32, Error>(1) })
.expect("registration failed");
let parent = owner
.register_workflow("parent", move |()| {
let child = child.clone();
async move { child.run(()).await }
})
.expect("registration failed");
other.launch().await.expect("launch failed");
owner.launch().await.expect("launch failed");
let other_slot = other.executor_slot();
drop(other);
let error = parent.run(()).await.expect_err("the parent succeeded");
assert!(
matches!(&error, Error::WrongInstance { operation } if operation.contains("workflow")),
"expected a wrong-instance refusal, got {error:?}"
);
owner.shutdown().await;
shut_down(&other_slot).await;
}
}