use std::sync::{Arc, RwLock};
use crate::config::{Config, Serializer};
use crate::connection::Connection;
use crate::identity::{Environment, Identity};
use crate::registry::{Registry, Snapshot};
use crate::sysdb::SystemDatabase;
use crate::workflow::Tasks;
use crate::{Error, Result};
pub struct Executor {
conn: Arc<Connection>,
executor_id: String,
app_version: String,
app_name: String,
app_id: String,
runtime: tokio::runtime::Handle,
workflows: Snapshot,
listen_queues: Option<Vec<String>>,
tasks: Tasks,
}
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: tokio::runtime::Handle::current(),
workflows,
app_name,
listen_queues: config.listen_queues.clone(),
tasks: Tasks::default(),
};
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) -> &Tasks {
&self.tasks
}
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) -> &tokio::runtime::Handle {
&self.runtime
}
pub(crate) async fn shutdown(&self) {
let cancelled = self.tasks.abort_all().await;
if cancelled > 0 {
tracing::info!(
cancelled,
"cancelled workflows still running; they stay PENDING"
);
}
self.conn.close().await;
}
}
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(Clone)]
pub struct DBOS(Arc<Inner>);
struct Inner {
config: Config,
registry: Registry,
executor: RwLock<Option<Arc<Executor>>>,
lifecycle: tokio::sync::Mutex<()>,
}
impl Drop for Inner {
fn drop(&mut self) {
if self.executor.get_mut().is_ok_and(|slot| slot.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: RwLock::new(None),
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::dequeue::spawn(executor);
Ok(())
}
pub async fn shutdown(&self) {
let _lifecycle = self.0.lifecycle.lock().await;
let Some(executor) = self.write_executor().take() else {
return;
};
executor.shutdown().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.read_executor().ok_or(Error::NotLaunched {
operation: operation.into(),
})
}
fn read_executor(&self) -> Option<Arc<Executor>> {
self.0
.executor
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
fn write_executor(&self) -> std::sync::RwLockWriteGuard<'_, Option<Arc<Executor>>> {
self.0
.executor
.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}");
}
}