use crate::context::{AuthContext, DurableContext};
use crate::error::{panic_message, Error, ErrorCode, Result};
use crate::handle::WorkflowHandle;
use crate::provider::{
is_terminal, DequeueRequest, ForkParams, ListFilter, StateProvider, StepAggregate,
StepAggregateQuery, StepInfo, VersionInfo, WorkflowAggregate, WorkflowAggregateQuery,
WorkflowStatus, STATUS_CANCELLED, STATUS_DELAYED, STATUS_ENQUEUED, STATUS_ERROR,
STATUS_PENDING, STATUS_SUCCESS,
};
use crate::queue::WorkflowQueue;
use crate::schedule::{
ApplySchedule, ScheduleFilter, ScheduleOptions, ScheduleStatus, WorkflowSchedule,
};
use chrono::{DateTime, Utc};
use futures_util::FutureExt;
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::future::Future;
use std::panic::AssertUnwindSafe;
use std::pin::Pin;
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::task::JoinHandle;
const SCHEDULE_RECONCILE_INTERVAL: Duration = Duration::from_millis(500);
pub(crate) const INTERNAL_QUEUE: &str = "_dbos_internal_queue";
fn internal_queue() -> WorkflowQueue {
let mut q = WorkflowQueue::new(INTERNAL_QUEUE);
q.base_polling_interval = Duration::from_millis(100);
q
}
pub type WorkflowFn = Arc<
dyn Fn(DurableContext, Value) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>>
+ Send
+ Sync,
>;
pub(crate) fn registry_key(name: &str, config_name: Option<&str>) -> String {
match config_name {
Some(c) if !c.is_empty() => format!("{name}/{c}"),
_ => name.to_string(),
}
}
pub fn erase<I, O, F, Fut>(f: F) -> WorkflowFn
where
I: DeserializeOwned + Send + 'static,
O: Serialize + Send + 'static,
F: Fn(DurableContext, I) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<O>> + Send + 'static,
{
let f = Arc::new(f);
Arc::new(move |ctx, input_json| {
let f = f.clone();
Box::pin(async move {
let input: I = serde_json::from_value(input_json)?;
let output: O = f(ctx, input).await?;
Ok(serde_json::to_value(output)?)
})
})
}
pub struct WorkflowRegistration {
pub name: &'static str,
pub builder: fn() -> WorkflowFn,
pub schedule: Option<&'static str>,
}
inventory::collect!(WorkflowRegistration);
pub trait WorkflowDef {
type Input;
type Output;
const NAME: &'static str;
}
#[doc(hidden)]
pub trait WorkflowResult {
type Ok;
}
impl<T, E> WorkflowResult for std::result::Result<T, E> {
type Ok = T;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RegisteredWorkflow {
pub name: String,
pub cron_schedule: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct EngineConfig {
pub app_version: Option<String>,
pub executor_id: Option<String>,
}
impl EngineConfig {
pub fn app_version(mut self, version: impl Into<String>) -> Self {
self.app_version = Some(version.into());
self
}
pub fn executor_id(mut self, id: impl Into<String>) -> Self {
self.executor_id = Some(id.into());
self
}
pub(crate) fn resolve_app_version(&self) -> String {
if let Ok(v) = std::env::var("DBOS__APPVERSION") {
if !v.is_empty() {
return v;
}
}
if let Some(v) = &self.app_version {
return v.clone();
}
binary_version().to_string()
}
pub(crate) fn resolve_executor_id(&self) -> String {
if let Ok(v) = std::env::var("DBOS__VMID") {
if !v.is_empty() {
return v;
}
}
if let Some(v) = &self.executor_id {
return v.clone();
}
"local".to_string()
}
}
fn binary_version() -> &'static str {
static HASH: std::sync::OnceLock<String> = std::sync::OnceLock::new();
HASH.get_or_init(|| {
let hash = || -> std::io::Result<String> {
use sha2::{Digest, Sha256};
let exe = std::env::current_exe()?.canonicalize()?;
let mut file = std::fs::File::open(exe)?;
let mut hasher = Sha256::new();
std::io::copy(&mut file, &mut hasher)?;
Ok(format!("{:x}", hasher.finalize()))
};
match hash() {
Ok(h) => h,
Err(e) => {
tracing::warn!(error = %e, "failed to hash the executable for the default application version");
String::new()
}
}
})
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DeduplicationPolicy {
#[default]
Reject,
ReturnExisting,
}
#[derive(Clone, Default)]
pub struct WorkflowOptions {
pub workflow_id: Option<String>,
pub dedup_id: Option<String>,
pub dedup_policy: DeduplicationPolicy,
pub app_version: Option<String>,
pub queue: Option<String>,
pub priority: i32,
pub partition_key: Option<String>,
pub timeout: Option<Duration>,
pub delay: Option<Duration>,
pub authenticated_user: Option<String>,
pub assumed_role: Option<String>,
pub authenticated_roles: Vec<String>,
pub config_name: Option<String>,
pub class_name: Option<String>,
}
impl WorkflowOptions {
pub fn with_id(id: impl Into<String>) -> Self {
Self {
workflow_id: Some(id.into()),
..Default::default()
}
}
pub fn queue(mut self, name: impl Into<String>) -> Self {
self.queue = Some(name.into());
self
}
pub fn authenticated_user(mut self, user: impl Into<String>) -> Self {
self.authenticated_user = Some(user.into());
self
}
pub fn assumed_role(mut self, role: impl Into<String>) -> Self {
self.assumed_role = Some(role.into());
self
}
pub fn partition_key(mut self, key: impl Into<String>) -> Self {
self.partition_key = Some(key.into());
self
}
pub fn dedup_id(mut self, id: impl Into<String>) -> Self {
self.dedup_id = Some(id.into());
self
}
pub fn dedup_policy(mut self, policy: DeduplicationPolicy) -> Self {
self.dedup_policy = policy;
self
}
pub fn config_name(mut self, name: impl Into<String>) -> Self {
self.config_name = Some(name.into());
self
}
pub fn class_name(mut self, name: impl Into<String>) -> Self {
self.class_name = Some(name.into());
self
}
pub fn app_version(mut self, version: impl Into<String>) -> Self {
self.app_version = Some(version.into());
self
}
pub fn authenticated_roles<I, S>(mut self, roles: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.authenticated_roles = roles.into_iter().map(Into::into).collect();
self
}
}
pub struct DurableEngine {
provider: Arc<dyn StateProvider>,
workflows: HashMap<String, WorkflowFn>,
queues: HashMap<String, Arc<WorkflowQueue>>,
listen_filter: Option<std::collections::HashSet<String>>,
scheduled: Vec<(String, String)>,
executor_id: String,
app_version: String,
max_recovery_attempts: i32,
shutting_down: Arc<AtomicBool>,
deactivated: Arc<AtomicBool>,
inflight: Arc<AtomicUsize>,
dispatchers: std::sync::Mutex<Vec<JoinHandle<()>>>,
runtime: std::sync::OnceLock<Arc<Runtime>>,
}
pub struct DurableEngineBuilder {
provider: Arc<dyn StateProvider>,
config: EngineConfig,
workflows: Vec<(String, WorkflowFn)>,
queues: Vec<WorkflowQueue>,
listen_filter: Option<std::collections::HashSet<String>>,
max_recovery_attempts: i32,
}
impl DurableEngineBuilder {
pub fn register<I, O, F, Fut>(&mut self, name: &str, f: F) -> &mut Self
where
I: DeserializeOwned + Send + 'static,
O: Serialize + Send + 'static,
F: Fn(DurableContext, I) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<O>> + Send + 'static,
{
self.workflows.push((name.to_string(), erase(f)));
self
}
pub fn register_configured<I, O, F, Fut>(
&mut self,
name: &str,
config_name: &str,
f: F,
) -> &mut Self
where
I: DeserializeOwned + Send + 'static,
O: Serialize + Send + 'static,
F: Fn(DurableContext, I) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<O>> + Send + 'static,
{
self.workflows
.push((registry_key(name, Some(config_name)), erase(f)));
self
}
pub fn register_queue(&mut self, queue: WorkflowQueue) -> &mut Self {
self.queues.push(queue);
self
}
pub fn listen_queues<I, S>(&mut self, names: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.listen_filter = Some(names.into_iter().map(Into::into).collect());
self
}
pub fn app_version(&mut self, version: impl Into<String>) -> &mut Self {
self.config.app_version = Some(version.into());
self
}
pub fn executor_id(&mut self, id: impl Into<String>) -> &mut Self {
self.config.executor_id = Some(id.into());
self
}
pub fn max_recovery_attempts(&mut self, max: i32) -> &mut Self {
self.max_recovery_attempts = max;
self
}
pub async fn build(self) -> Result<DurableEngine> {
let app_version = self.config.resolve_app_version();
let executor_id = self.config.resolve_executor_id();
self.provider.init().await?;
let mut workflows: HashMap<String, WorkflowFn> = HashMap::new();
let mut scheduled = Vec::new();
workflows.insert(
crate::debounce::DEBOUNCER_WF.to_string(),
erase(crate::debounce::internal_debouncer),
);
for reg in inventory::iter::<WorkflowRegistration> {
if workflows
.insert(reg.name.to_string(), (reg.builder)())
.is_some()
{
return Err(Error::conflicting_registration(reg.name));
}
if let Some(spec) = reg.schedule {
scheduled.push((reg.name.to_string(), spec.to_string()));
}
}
for (key, f) in self.workflows {
if workflows.insert(key.clone(), f).is_some() {
return Err(Error::conflicting_registration(key));
}
}
let mut queues = HashMap::new();
queues.insert(INTERNAL_QUEUE.to_string(), Arc::new(internal_queue()));
for q in self.queues {
let name = q.name.clone();
if queues.insert(name.clone(), Arc::new(q)).is_some() {
return Err(Error::conflicting_registration(name));
}
}
Ok(DurableEngine {
provider: self.provider,
workflows,
queues,
listen_filter: self.listen_filter,
scheduled,
executor_id,
app_version,
max_recovery_attempts: self.max_recovery_attempts,
shutting_down: Arc::new(AtomicBool::new(false)),
deactivated: Arc::new(AtomicBool::new(false)),
inflight: Arc::new(AtomicUsize::new(0)),
dispatchers: std::sync::Mutex::new(Vec::new()),
runtime: std::sync::OnceLock::new(),
})
}
}
impl DurableEngine {
pub async fn new(provider: Arc<dyn StateProvider>) -> Result<Self> {
Self::with_config(provider, EngineConfig::default()).await
}
pub async fn new_with_version(
provider: Arc<dyn StateProvider>,
app_version: impl Into<String>,
) -> Result<Self> {
Self::with_config(
provider,
EngineConfig {
app_version: Some(app_version.into()),
..Default::default()
},
)
.await
}
pub async fn with_config(
provider: Arc<dyn StateProvider>,
config: EngineConfig,
) -> Result<Self> {
let app_version = config.resolve_app_version();
let executor_id = config.resolve_executor_id();
provider.init().await?;
let mut workflows = HashMap::new();
let mut scheduled = Vec::new();
for reg in inventory::iter::<WorkflowRegistration> {
workflows.insert(reg.name.to_string(), (reg.builder)());
if let Some(spec) = reg.schedule {
scheduled.push((reg.name.to_string(), spec.to_string()));
}
}
workflows.insert(
crate::debounce::DEBOUNCER_WF.to_string(),
erase(crate::debounce::internal_debouncer),
);
let mut queues = HashMap::new();
queues.insert(INTERNAL_QUEUE.to_string(), Arc::new(internal_queue()));
Ok(Self {
provider,
workflows,
queues,
listen_filter: None,
scheduled,
executor_id,
app_version,
max_recovery_attempts: 100,
shutting_down: Arc::new(AtomicBool::new(false)),
deactivated: Arc::new(AtomicBool::new(false)),
inflight: Arc::new(AtomicUsize::new(0)),
dispatchers: std::sync::Mutex::new(Vec::new()),
runtime: std::sync::OnceLock::new(),
})
}
pub fn builder(provider: Arc<dyn StateProvider>) -> DurableEngineBuilder {
DurableEngineBuilder {
provider,
config: EngineConfig::default(),
workflows: Vec::new(),
queues: Vec::new(),
listen_filter: None,
max_recovery_attempts: 100,
}
}
pub async fn connect(url: &str) -> Result<DurableEngineBuilder> {
if url.starts_with("postgres://") || url.starts_with("postgresql://") {
#[cfg(feature = "postgres")]
return Ok(Self::builder(Arc::new(
crate::PostgresProvider::connect(url).await?,
)));
#[cfg(not(feature = "postgres"))]
return Err(crate::error::Error::app(format!(
"`{url}` is a Postgres URL but the `postgres` feature is not enabled"
)));
}
if url.starts_with("sqlite:") {
#[cfg(feature = "sqlite")]
return Ok(Self::builder(Arc::new(
crate::SqliteProvider::connect(url).await?,
)));
#[cfg(not(feature = "sqlite"))]
return Err(crate::error::Error::app(format!(
"`{url}` is a SQLite URL but the `sqlite` feature is not enabled"
)));
}
if url == "memory:" || url == "memory://" {
return Ok(Self::builder(Arc::new(crate::InMemoryProvider::new())));
}
Err(crate::error::Error::app(format!(
"unrecognized state-backend URL scheme in `{url}` \
(expected postgres://, sqlite://, or memory:)"
)))
}
fn runtime(&self) -> Arc<Runtime> {
self.runtime
.get_or_init(|| {
Arc::new(Runtime {
provider: self.provider.clone(),
workflows: self.workflows.clone(),
queues: self.queues.clone(),
executor_id: self.executor_id.clone(),
app_version: self.app_version.clone(),
inflight: self.inflight.clone(),
})
})
.clone()
}
pub fn executor_id(&self) -> &str {
&self.executor_id
}
pub fn app_version(&self) -> &str {
&self.app_version
}
pub(crate) fn provider(&self) -> &Arc<dyn StateProvider> {
&self.provider
}
pub fn debouncer(&self, target_workflow: &str) -> crate::debounce::Debouncer<'_> {
crate::debounce::Debouncer::new(self, target_workflow)
}
pub fn register<I, O, F, Fut>(&mut self, name: &str, f: F)
where
I: DeserializeOwned + Send + 'static,
O: Serialize + Send + 'static,
F: Fn(DurableContext, I) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<O>> + Send + 'static,
{
self.workflows.insert(name.to_string(), erase(f));
}
pub fn register_configured<I, O, F, Fut>(&mut self, name: &str, config_name: &str, f: F)
where
I: DeserializeOwned + Send + 'static,
O: Serialize + Send + 'static,
F: Fn(DurableContext, I) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<O>> + Send + 'static,
{
self.workflows
.insert(registry_key(name, Some(config_name)), erase(f));
}
pub fn register_queue(&mut self, queue: WorkflowQueue) {
self.queues.insert(queue.name.clone(), Arc::new(queue));
}
pub fn listen_queues<I, S>(&mut self, names: I)
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.listen_filter = Some(names.into_iter().map(Into::into).collect());
}
pub fn list_registered_queues(&self) -> Vec<WorkflowQueue> {
let mut queues: Vec<WorkflowQueue> = self
.queues
.values()
.filter(|q| q.name != INTERNAL_QUEUE)
.map(|q| (**q).clone())
.collect();
queues.sort_by(|a, b| a.name.cmp(&b.name));
queues
}
pub async fn list_queues(&self) -> Result<Vec<WorkflowQueue>> {
self.provider.list_queues().await
}
pub fn list_registered_workflows(&self) -> Vec<RegisteredWorkflow> {
let schedules: HashMap<&str, &str> = self
.scheduled
.iter()
.map(|(name, spec)| (name.as_str(), spec.as_str()))
.collect();
let mut out: Vec<RegisteredWorkflow> = self
.workflows
.keys()
.map(|name| RegisteredWorkflow {
name: name.clone(),
cron_schedule: schedules.get(name.as_str()).map(|s| s.to_string()),
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn list_scheduled_workflows(&self) -> Vec<RegisteredWorkflow> {
let mut out = self.list_registered_workflows();
out.retain(|w| w.cron_schedule.is_some());
out
}
#[doc(alias = "cron")]
pub async fn create_schedule(
&self,
schedule_name: &str,
workflow_name: &str,
cron: &str,
opts: ScheduleOptions,
) -> Result<()> {
if schedule_name.is_empty() {
return Err(Error::app("schedule_name is required"));
}
parse_cron(cron)?;
if let Some(tz) = &opts.cron_timezone {
parse_timezone(tz)?;
}
if !self.workflows.contains_key(workflow_name) {
return Err(Error::UnknownWorkflow(workflow_name.to_string()));
}
let schedule = WorkflowSchedule {
schedule_id: uuid::Uuid::new_v4().to_string(),
schedule_name: schedule_name.to_string(),
workflow_name: workflow_name.to_string(),
schedule: cron.to_string(),
status: ScheduleStatus::Active,
context: opts.context,
last_fired_at: None,
automatic_backfill: opts.automatic_backfill,
cron_timezone: opts.cron_timezone,
queue_name: opts.queue_name,
};
self.provider.create_schedule(&schedule).await
}
pub async fn get_schedule(&self, schedule_name: &str) -> Result<Option<WorkflowSchedule>> {
let schedules = self
.provider
.list_schedules(&ScheduleFilter {
name_prefixes: vec![schedule_name.to_string()],
..Default::default()
})
.await?;
Ok(schedules
.into_iter()
.find(|s| s.schedule_name == schedule_name))
}
pub async fn list_schedules(&self, filter: &ScheduleFilter) -> Result<Vec<WorkflowSchedule>> {
self.provider.list_schedules(filter).await
}
pub async fn pause_schedule(&self, schedule_name: &str) -> Result<bool> {
self.provider
.set_schedule_status(schedule_name, ScheduleStatus::Paused)
.await
}
pub async fn resume_schedule(&self, schedule_name: &str) -> Result<bool> {
self.provider
.set_schedule_status(schedule_name, ScheduleStatus::Active)
.await
}
pub async fn delete_schedule(&self, schedule_name: &str) -> Result<bool> {
self.provider.delete_schedule(schedule_name).await
}
pub async fn apply_schedules(&self, schedules: Vec<ApplySchedule>) -> Result<()> {
for req in &schedules {
if req.schedule_name.is_empty() {
return Err(Error::app("schedule_name is required"));
}
parse_cron(&req.schedule)?;
if let Some(tz) = &req.options.cron_timezone {
parse_timezone(tz)?;
}
if !self.workflows.contains_key(&req.workflow_name) {
return Err(Error::UnknownWorkflow(req.workflow_name.clone()));
}
}
let built: Vec<WorkflowSchedule> = schedules
.into_iter()
.map(|req| WorkflowSchedule {
schedule_id: uuid::Uuid::new_v4().to_string(),
schedule_name: req.schedule_name,
workflow_name: req.workflow_name,
schedule: req.schedule,
status: ScheduleStatus::Active,
context: req.options.context,
last_fired_at: None,
automatic_backfill: req.options.automatic_backfill,
cron_timezone: req.options.cron_timezone,
queue_name: req.options.queue_name,
})
.collect();
self.provider.apply_schedules(&built).await
}
pub async fn backfill_schedule(
&self,
schedule_name: &str,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<Vec<String>> {
let schedule = self
.get_schedule(schedule_name)
.await?
.ok_or_else(|| Error::app(format!("schedule not found: {schedule_name}")))?;
backfill_ticks(&self.runtime(), &schedule, start, end).await
}
pub async fn trigger_schedule<O>(&self, schedule_name: &str) -> Result<WorkflowHandle<O>>
where
O: DeserializeOwned,
{
let schedule = self
.get_schedule(schedule_name)
.await?
.ok_or_else(|| Error::app(format!("schedule not found: {schedule_name}")))?;
let now = Utc::now();
let stamp = now.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true);
let opts = WorkflowOptions {
workflow_id: Some(format!("sched-{schedule_name}-trigger-{stamp}")),
queue: schedule.queue_name.clone(),
..Default::default()
};
self.start(&schedule.workflow_name, schedule.tick_input(now), opts)
.await
}
pub async fn list_application_versions(&self) -> Result<Vec<VersionInfo>> {
self.provider.list_application_versions().await
}
pub async fn get_latest_application_version(&self) -> Result<Option<VersionInfo>> {
self.provider.get_latest_application_version().await
}
pub async fn set_latest_application_version(&self, version_name: &str) -> Result<bool> {
if version_name.is_empty() {
return Err(Error::app("version_name is required"));
}
self.provider
.set_latest_application_version(version_name)
.await
}
pub async fn launch(&self) -> Result<()> {
if self.is_deactivated() {
return Ok(());
}
self.shutting_down.store(false, Ordering::Relaxed);
let rt = self.runtime();
if let Err(e) = self
.provider
.create_application_version(&self.app_version)
.await
{
tracing::warn!(version = %self.app_version, error = %e, "failed to register application version");
}
let update_existing = match self.provider.get_latest_application_version().await {
Ok(Some(latest)) => {
if latest.version_name != self.app_version {
tracing::warn!(
current = %self.app_version, latest = %latest.version_name,
"current application version is not the latest"
);
}
latest.version_name == self.app_version
}
Ok(None) => true, Err(_) => false, };
for queue in self.queues.values() {
if queue.name == INTERNAL_QUEUE {
continue;
}
if let Err(e) = self.provider.upsert_queue(queue, update_existing).await {
tracing::warn!(queue = %queue.name, error = %e, "failed to persist queue to the registry");
}
}
let mut tasks = self.dispatchers.lock().expect("dispatcher lock poisoned");
for queue in self.queues.values() {
if let Some(listen) = &self.listen_filter {
if queue.name != INTERNAL_QUEUE && !listen.contains(&queue.name) {
continue;
}
}
tasks.push(tokio::spawn(queue_dispatch_loop(
queue.clone(),
rt.clone(),
self.shutting_down.clone(),
)));
}
tasks.push(tokio::spawn(schedule_reconciler(
rt.clone(),
self.shutting_down.clone(),
self.macro_schedules(),
)));
Ok(())
}
fn macro_schedules(&self) -> Vec<WorkflowSchedule> {
self.scheduled
.iter()
.map(|(name, spec)| WorkflowSchedule {
schedule_id: format!("macro:{name}:{spec}"),
schedule_name: name.clone(),
workflow_name: name.clone(),
schedule: spec.clone(),
status: ScheduleStatus::Active,
context: None,
last_fired_at: None,
automatic_backfill: false,
cron_timezone: None,
queue_name: None,
})
.collect()
}
pub fn deactivate(&self) {
if self.deactivated.swap(true, Ordering::SeqCst) {
return;
}
tracing::info!(executor = %self.executor_id, "deactivating executor: stopping dispatch");
for d in self
.dispatchers
.lock()
.expect("dispatcher lock poisoned")
.drain(..)
{
d.abort();
}
}
pub fn is_deactivated(&self) -> bool {
self.deactivated.load(Ordering::SeqCst)
}
pub async fn cancel_all_before(&self, cutoff_epoch_ms: i64) -> Result<usize> {
let filter = ListFilter {
status: vec![
STATUS_PENDING.to_string(),
STATUS_ENQUEUED.to_string(),
STATUS_DELAYED.to_string(),
],
end_time_ms: Some(cutoff_epoch_ms),
load_input: false,
load_output: false,
..Default::default()
};
let ids: Vec<String> = self
.provider
.list_workflows(&filter)
.await?
.into_iter()
.map(|w| w.id)
.collect();
if ids.is_empty() {
return Ok(0);
}
self.cancel_workflows(&ids).await?;
Ok(ids.len())
}
pub async fn shutdown(&self, timeout: Duration) -> Result<()> {
self.shutting_down.store(true, Ordering::Relaxed);
for d in self
.dispatchers
.lock()
.expect("dispatcher lock poisoned")
.drain(..)
{
d.abort();
}
let deadline = std::time::Instant::now() + timeout;
while self.inflight.load(Ordering::Acquire) > 0 {
if std::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
Ok(())
}
pub async fn start<I, O>(
&self,
name: &str,
input: I,
opts: WorkflowOptions,
) -> Result<WorkflowHandle<O>>
where
I: Serialize,
{
let rt = self.runtime();
let id = opts
.workflow_id
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let input_json = serde_json::to_value(input)?;
if opts.dedup_policy != DeduplicationPolicy::Reject && opts.dedup_id.is_none() {
return Err(Error::app(
"a deduplication policy requires a deduplication id",
));
}
let handler = rt
.workflows
.get(®istry_key(name, opts.config_name.as_deref()))
.cloned()
.ok_or_else(|| Error::UnknownWorkflow(name.to_string()))?;
let auth = AuthContext {
authenticated_user: opts.authenticated_user.clone(),
assumed_role: opts.assumed_role.clone(),
authenticated_roles: opts.authenticated_roles.clone(),
};
let (canonical, queued, _created) = loop {
match rt
.insert_run(&id, name, input_json.clone(), &opts, None, &auth)
.await
{
Ok(v) => break v,
Err(e)
if opts.dedup_policy == DeduplicationPolicy::ReturnExisting
&& e.code() == ErrorCode::QueueDeduplicated =>
{
if let (Some(q), Some(d)) = (opts.queue.as_deref(), opts.dedup_id.as_deref()) {
if let Some(existing) =
self.provider.get_deduplicated_workflow(q, d).await?
{
return Ok(WorkflowHandle::polling(existing, self.provider.clone()));
}
}
}
Err(e) => return Err(e),
}
};
if queued || is_terminal(&canonical.status) {
return Ok(WorkflowHandle::polling(id, self.provider.clone()));
}
let join = rt.spawn_owned(
id.clone(),
handler,
canonical.input,
canonical.deadline_ms,
auth,
);
Ok(WorkflowHandle::local(id, self.provider.clone(), join))
}
pub async fn start_with<W>(
&self,
_wf: W,
input: W::Input,
opts: WorkflowOptions,
) -> Result<WorkflowHandle<W::Output>>
where
W: WorkflowDef,
W::Input: Serialize,
{
self.start::<W::Input, W::Output>(W::NAME, input, opts)
.await
}
pub async fn send<T: Serialize>(
&self,
destination_id: &str,
message: T,
topic: &str,
) -> Result<()> {
self.provider
.insert_notification(destination_id, topic, serde_json::to_value(message)?, None)
.await
}
pub async fn send_with_idempotency_key<T: Serialize>(
&self,
destination_id: &str,
message: T,
topic: &str,
idempotency_key: &str,
) -> Result<()> {
self.provider
.insert_notification(
destination_id,
topic,
serde_json::to_value(message)?,
Some(idempotency_key),
)
.await
}
pub async fn get_event<T: DeserializeOwned>(
&self,
target_workflow_id: &str,
key: &str,
timeout: Duration,
) -> Result<Option<T>> {
let deadline = std::time::Instant::now() + timeout;
loop {
if let Some(value) = self
.provider
.get_event_value(target_workflow_id, key)
.await?
{
return Ok(Some(serde_json::from_value(value)?));
}
let now = std::time::Instant::now();
if now >= deadline {
return Ok(None);
}
tokio::time::sleep((deadline - now).min(Duration::from_millis(25))).await;
}
}
pub async fn retrieve_workflow<O>(&self, id: &str) -> Result<WorkflowHandle<O>> {
self.provider
.get_workflow_status(id)
.await?
.ok_or_else(|| Error::UnknownWorkflow(id.to_string()))?;
Ok(WorkflowHandle::polling(
id.to_string(),
self.provider.clone(),
))
}
pub async fn list_workflows(&self, filter: &ListFilter) -> Result<Vec<WorkflowStatus>> {
self.provider.list_workflows(filter).await
}
pub async fn get_workflow_aggregates(
&self,
query: &WorkflowAggregateQuery,
) -> Result<Vec<WorkflowAggregate>> {
if query.is_empty() {
return Err(Error::app(
"get_workflow_aggregates requires at least one grouping dimension",
));
}
if query.no_select() {
return Err(Error::app(
"get_workflow_aggregates requires at least one selected aggregate",
));
}
self.provider.get_workflow_aggregates(query).await
}
pub async fn get_step_aggregates(
&self,
query: &StepAggregateQuery,
) -> Result<Vec<StepAggregate>> {
if query.no_grouping() {
return Err(Error::app(
"get_step_aggregates requires at least one grouping dimension",
));
}
if query.no_select() {
return Err(Error::app(
"get_step_aggregates requires at least one selected aggregate",
));
}
self.provider.get_step_aggregates(query).await
}
pub async fn get_workflow_steps(&self, workflow_id: &str) -> Result<Vec<StepInfo>> {
self.provider.get_workflow_steps(workflow_id).await
}
pub async fn list_workflow_events(&self, workflow_id: &str) -> Result<Vec<(String, Value)>> {
self.provider.list_workflow_events(workflow_id).await
}
pub async fn list_workflow_notifications(
&self,
workflow_id: &str,
) -> Result<Vec<crate::provider::NotificationInfo>> {
self.provider.list_workflow_notifications(workflow_id).await
}
pub async fn list_workflow_streams(
&self,
workflow_id: &str,
) -> Result<Vec<(String, Vec<Value>)>> {
self.provider.list_workflow_streams(workflow_id).await
}
pub async fn export_workflow(
&self,
workflow_id: &str,
export_children: bool,
) -> Result<Vec<crate::provider::ExportedWorkflow>> {
self.provider
.export_workflow(workflow_id, export_children)
.await
}
pub async fn import_workflow(
&self,
workflows: &[crate::provider::ExportedWorkflow],
) -> Result<()> {
self.provider.import_workflow(workflows).await
}
pub async fn read_stream<T: DeserializeOwned>(
&self,
workflow_id: &str,
key: &str,
) -> Result<(Vec<T>, bool)> {
crate::provider::drain_stream(self.provider.as_ref(), workflow_id, key).await
}
pub async fn read_stream_snapshot<T: DeserializeOwned>(
&self,
workflow_id: &str,
key: &str,
from_offset: i32,
) -> Result<(Vec<T>, bool)> {
crate::provider::snapshot_stream(self.provider.as_ref(), workflow_id, key, from_offset)
.await
}
pub fn read_stream_values<T: DeserializeOwned + 'static>(
&self,
workflow_id: &str,
key: &str,
) -> impl futures_util::Stream<Item = Result<T>> + '_ {
crate::provider::stream_values(self.provider.as_ref(), workflow_id, key)
}
pub async fn cancel_workflow(&self, id: &str) -> Result<()> {
self.provider.cancel_workflow(id).await
}
pub async fn resume_workflow<O>(&self, id: &str) -> Result<WorkflowHandle<O>> {
self.resume_workflow_on(id, INTERNAL_QUEUE).await
}
pub async fn resume_workflow_on<O>(&self, id: &str, queue: &str) -> Result<WorkflowHandle<O>> {
if self.provider.resume_workflow(id).await? {
self.provider.enqueue_existing(id, queue).await?;
} else if self.provider.get_workflow_status(id).await?.is_none() {
return Err(Error::nonexistent_workflow(id));
}
Ok(WorkflowHandle::polling(
id.to_string(),
self.provider.clone(),
))
}
pub async fn cancel_workflows(&self, ids: &[String]) -> Result<()> {
self.provider.cancel_workflows(ids).await
}
pub async fn resume_workflows<O>(&self, ids: &[String]) -> Result<Vec<WorkflowHandle<O>>> {
self.resume_workflows_on(ids, INTERNAL_QUEUE).await
}
pub async fn resume_workflows_on<O>(
&self,
ids: &[String],
queue: &str,
) -> Result<Vec<WorkflowHandle<O>>> {
let resumed = self.provider.resume_workflows(ids).await?;
for id in &resumed {
self.provider.enqueue_existing(id, queue).await?;
}
let existing: std::collections::HashSet<String> = self
.provider
.list_workflows(&ListFilter {
workflow_ids: ids.to_vec(),
..Default::default()
})
.await?
.into_iter()
.map(|w| w.id)
.collect();
Ok(ids
.iter()
.filter(|id| existing.contains(*id))
.map(|id| WorkflowHandle::polling(id.clone(), self.provider.clone()))
.collect())
}
pub async fn delete_workflows(&self, ids: &[String], delete_children: bool) -> Result<()> {
self.provider.delete_workflows(ids, delete_children).await
}
pub async fn set_workflow_delay(&self, id: &str, delay: Duration) -> Result<bool> {
let until = chrono::Utc::now().timestamp_millis() + delay.as_millis() as i64;
self.provider.set_workflow_delay(id, until).await
}
pub async fn set_workflow_delay_until(
&self,
id: &str,
at: chrono::DateTime<chrono::Utc>,
) -> Result<bool> {
self.provider
.set_workflow_delay(id, at.timestamp_millis())
.await
}
pub async fn fork_workflow<O>(
&self,
original_id: &str,
start_step: i32,
opts: WorkflowOptions,
) -> Result<WorkflowHandle<O>> {
if opts.partition_key.is_some() && opts.queue.is_none() {
return Err(crate::error::Error::app(
"a queue partition key requires a queue name",
));
}
let new_id = opts
.workflow_id
.filter(|s| !s.is_empty())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
self.provider
.fork_workflow(&ForkParams {
original_id: original_id.to_string(),
new_id: new_id.clone(),
start_step,
app_version: opts.app_version.filter(|v| !v.is_empty()),
queue_name: opts.queue.unwrap_or_else(|| INTERNAL_QUEUE.to_string()),
partition_key: opts.partition_key,
})
.await?;
Ok(WorkflowHandle::polling(new_id, self.provider.clone()))
}
pub async fn recover(&self) -> Result<usize> {
Ok(self.recover_pending_for(&[]).await?.len())
}
pub async fn recover_pending_for(&self, executor_ids: &[String]) -> Result<Vec<String>> {
let filter = ListFilter {
status: vec![STATUS_PENDING.to_string()],
app_version: vec![self.app_version.clone()],
executor_ids: executor_ids.to_vec(),
..Default::default()
};
let pending = self.provider.list_workflows(&filter).await?;
let rt = self.runtime();
let mut recovered = Vec::new();
for record in pending {
let attempts = self
.provider
.bump_recovery_attempts(&record.id, self.max_recovery_attempts)
.await?;
if attempts > self.max_recovery_attempts {
tracing::warn!(
id = %record.id,
attempts,
"workflow parked: exceeded max recovery attempts"
);
continue;
}
if record.queue_name.is_some() {
self.provider
.set_workflow_status(&record.id, STATUS_ENQUEUED, None, None)
.await?;
recovered.push(record.id);
continue;
}
if let Some(handler) = rt
.workflows
.get(®istry_key(&record.name, record.config_name.as_deref()))
.cloned()
{
let _ = run_to_completion(
rt.clone(),
handler,
record.id.clone(),
record.input.clone(),
record.deadline_ms,
AuthContext::from_status(&record),
)
.await;
recovered.push(record.id);
} else {
tracing::warn!(
workflow = %record.name,
id = %record.id,
"skipping recovery: no handler registered for this workflow name"
);
}
}
Ok(recovered)
}
}
pub(crate) struct Runtime {
provider: Arc<dyn StateProvider>,
workflows: HashMap<String, WorkflowFn>,
queues: HashMap<String, Arc<WorkflowQueue>>,
executor_id: String,
app_version: String,
inflight: Arc<AtomicUsize>,
}
impl Runtime {
pub(crate) fn provider(&self) -> &Arc<dyn StateProvider> {
&self.provider
}
async fn insert_run(
&self,
id: &str,
name: &str,
input_json: Value,
opts: &WorkflowOptions,
parent_id: Option<&str>,
auth: &AuthContext,
) -> Result<(WorkflowStatus, bool, bool)> {
let queued = opts.queue.is_some();
if let Some(q) = &opts.queue {
if !self.queues.contains_key(q) {
return Err(Error::UnknownQueue(q.clone()));
}
}
if opts.delay.is_some() && !queued {
return Err(Error::app(
"WorkflowOptions.delay requires a queue; direct runs start immediately",
));
}
let now_ms = chrono::Utc::now().timestamp_millis();
let status = match (queued, opts.delay) {
(false, _) => STATUS_PENDING,
(true, None) => STATUS_ENQUEUED,
(true, Some(_)) => STATUS_DELAYED,
};
let executor = if queued {
""
} else {
self.executor_id.as_str()
};
let app_version = opts.app_version.as_deref().unwrap_or(&self.app_version);
let mut row = WorkflowStatus::new(id, name, input_json, status, executor, app_version);
row.queue_name = opts.queue.clone();
row.priority = opts.priority;
row.queue_partition_key = opts.partition_key.clone();
row.dedup_id = opts.dedup_id.clone();
row.parent_workflow_id = parent_id.map(|s| s.to_string());
row.authenticated_user = auth.authenticated_user.clone();
row.assumed_role = auth.assumed_role.clone();
row.authenticated_roles = auth.authenticated_roles.clone();
row.class_name = opts.class_name.clone();
row.config_name = opts.config_name.clone();
row.timeout_ms = opts.timeout.map(|d| d.as_millis() as i64);
row.delay_until_ms = opts.delay.map(|d| now_ms + d.as_millis() as i64);
if !queued {
let created_ms = row.created_at.timestamp_millis();
row.started_at_ms = Some(created_ms);
row.deadline_ms = row.timeout_ms.map(|t| created_ms + t);
}
let (canonical, created) = self.provider.insert_workflow_status(row).await?;
Ok((canonical, queued, created))
}
fn spawn_owned(
self: &Arc<Self>,
id: String,
handler: WorkflowFn,
input: Value,
deadline_ms: Option<i64>,
auth: AuthContext,
) -> JoinHandle<Result<Value>> {
let rt = self.clone();
self.inflight.fetch_add(1, Ordering::Relaxed);
let guard = InflightGuard(self.inflight.clone());
tokio::spawn(async move {
let _guard = guard;
run_to_completion(rt, handler, id, input, deadline_ms, auth).await
})
}
fn spawn_detached(
self: &Arc<Self>,
id: String,
handler: WorkflowFn,
input: Value,
deadline_ms: Option<i64>,
auth: AuthContext,
) {
let join = self.spawn_owned(id, handler, input, deadline_ms, auth);
drop(join);
}
async fn persist_scheduled_tick(
&self,
schedule: &WorkflowSchedule,
instant: DateTime<Utc>,
) -> Result<(WorkflowStatus, bool, bool, String)> {
let wf_id = format!("sched-{}-{}", schedule.schedule_name, instant.to_rfc3339());
let opts = WorkflowOptions {
workflow_id: Some(wf_id.clone()),
queue: schedule.queue_name.clone(),
..Default::default()
};
let auth = AuthContext::default();
let input = serde_json::to_value(schedule.tick_input(instant))?;
let (canonical, queued, created) = self
.insert_run(&wf_id, &schedule.workflow_name, input, &opts, None, &auth)
.await?;
Ok((canonical, queued, created, wf_id))
}
fn launch_scheduled_tick(
self: &Arc<Self>,
schedule: &WorkflowSchedule,
canonical: WorkflowStatus,
queued: bool,
created: bool,
id: &str,
) {
if queued || !created || is_terminal(&canonical.status) {
return;
}
if let Some(handler) = self.workflows.get(&schedule.workflow_name).cloned() {
self.spawn_detached(
id.to_string(),
handler,
canonical.input,
canonical.deadline_ms,
AuthContext::default(),
);
}
}
pub(crate) async fn spawn_child(
self: &Arc<Self>,
child_id: &str,
name: &str,
input_json: Value,
opts: WorkflowOptions,
parent_id: &str,
auth: AuthContext,
) -> Result<()> {
let handler = self
.workflows
.get(®istry_key(name, opts.config_name.as_deref()))
.cloned()
.ok_or_else(|| Error::UnknownWorkflow(name.to_string()))?;
let (canonical, queued, _created) = self
.insert_run(child_id, name, input_json, &opts, Some(parent_id), &auth)
.await?;
if !queued && !is_terminal(&canonical.status) {
self.spawn_detached(
child_id.to_string(),
handler,
canonical.input,
canonical.deadline_ms,
auth,
);
}
Ok(())
}
}
struct InflightGuard(Arc<AtomicUsize>);
impl Drop for InflightGuard {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Release);
}
}
async fn queue_dispatch_loop(
queue: Arc<WorkflowQueue>,
rt: Arc<Runtime>,
shutting_down: Arc<AtomicBool>,
) {
let provider = rt.provider.clone();
let executor_id = rt.executor_id.clone();
let app_version = rt.app_version.clone();
let inflight = rt.inflight.clone();
let local_running: std::sync::Mutex<HashMap<String, Arc<AtomicUsize>>> = Default::default();
let mut interval = queue.base_polling_interval;
loop {
if shutting_down.load(Ordering::Relaxed) {
return;
}
let now_ms = chrono::Utc::now().timestamp_millis();
if let Err(e) = provider.transition_delayed_workflows(now_ms).await {
tracing::warn!(queue = %queue.name, error = %e, "failed to transition delayed workflows");
}
let mut had_error = false;
let partition_keys: Vec<Option<String>> = if queue.partitioned {
match provider.queue_partitions(&queue.name).await {
Ok(keys) => keys.into_iter().map(Some).collect(),
Err(e) => {
had_error = true;
tracing::warn!(queue = %queue.name, error = %e, "listing partitions failed; backing off");
Vec::new()
}
}
} else {
vec![None]
};
for pkey in partition_keys {
let counter = {
let mut map = local_running.lock().expect("local running lock poisoned");
map.entry(pkey.clone().unwrap_or_default())
.or_insert_with(|| Arc::new(AtomicUsize::new(0)))
.clone()
};
let local = counter.load(Ordering::Relaxed);
let max_tasks = (match queue.worker_concurrency {
Some(wc) => wc.saturating_sub(local),
None => queue.max_tasks_per_iteration,
})
.min(queue.max_tasks_per_iteration) as i64;
if max_tasks <= 0 {
continue;
}
let req = DequeueRequest {
queue_name: queue.name.clone(),
executor_id: executor_id.clone(),
app_version: app_version.clone(),
partition_key: pkey.clone(),
max_tasks,
global_concurrency: queue.global_concurrency,
rate_limit_max: queue.rate_limit.as_ref().map(|r| r.limit),
rate_limit_period_ms: queue
.rate_limit
.as_ref()
.map(|r| r.period.as_millis() as i64),
};
match provider.dequeue_workflows(&req).await {
Ok(claimed) => {
for wf in claimed {
let Some(handler) = rt
.workflows
.get(®istry_key(&wf.name, wf.config_name.as_deref()))
.cloned()
else {
tracing::error!(
workflow = %wf.name,
id = %wf.id,
"dequeued workflow has no registered handler; releasing the claim"
);
let _ = provider
.set_workflow_status(&wf.id, STATUS_ENQUEUED, None, None)
.await;
continue;
};
inflight.fetch_add(1, Ordering::Relaxed);
counter.fetch_add(1, Ordering::Relaxed);
let rt = rt.clone();
let inflight_guard = InflightGuard(inflight.clone());
let local_guard = InflightGuard(counter.clone());
let auth = AuthContext::from_status(&wf);
tokio::spawn(async move {
let _inflight = inflight_guard;
let _local = local_guard;
let _ = run_to_completion(
rt,
handler,
wf.id,
wf.input,
wf.deadline_ms,
auth,
)
.await;
});
}
}
Err(e) => {
had_error = true;
tracing::warn!(queue = %queue.name, error = %e, "dequeue failed; backing off");
}
}
}
interval = if had_error {
(interval * 2).min(queue.max_polling_interval)
} else {
interval.mul_f64(0.9).max(queue.base_polling_interval)
};
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let jitter = 0.95 + (nanos % 1000) as f64 / 10_000.0;
tokio::time::sleep(interval.mul_f64(jitter)).await;
}
}
async fn run_to_completion(
rt: Arc<Runtime>,
handler: WorkflowFn,
id: String,
input: Value,
deadline_ms: Option<i64>,
auth: AuthContext,
) -> Result<Value> {
let provider = rt.provider().clone();
let ctx = DurableContext::new(id.clone(), rt, auth);
let run = AssertUnwindSafe(handler(ctx, input)).catch_unwind();
let caught = match deadline_ms {
Some(dl) => {
let remaining = (dl - chrono::Utc::now().timestamp_millis()).max(0) as u64;
match tokio::time::timeout(Duration::from_millis(remaining), run).await {
Ok(caught) => caught,
Err(_elapsed) => {
provider
.set_workflow_status(&id, STATUS_CANCELLED, None, Some("deadline exceeded"))
.await?;
return Err(Error::Timeout);
}
}
}
None => run.await,
};
let result = match caught {
Ok(returned) => returned,
Err(payload) => {
let msg = panic_message(&*payload);
tracing::error!(id = %id, panic = %msg, "workflow panicked; left recoverable for recovery to re-run");
return Err(Error::app(format!("workflow panicked: {msg}")));
}
};
match result {
Ok(output) => {
provider
.set_workflow_status(&id, STATUS_SUCCESS, Some(&output), None)
.await?;
Ok(output)
}
Err(Error::Cancelled(_)) => {
provider
.set_workflow_status(&id, STATUS_CANCELLED, None, Some("cancelled"))
.await?;
Err(Error::Cancelled(id))
}
Err(e) => {
let stored = crate::serialize::encode_error(&provider.serializer(), &e);
provider
.set_workflow_status(&id, STATUS_ERROR, None, Some(&stored))
.await?;
Err(e)
}
}
}
struct InstalledSchedule {
schedule_id: String,
stop: Arc<AtomicBool>,
}
pub(crate) fn parse_cron(spec: &str) -> Result<cron::Schedule> {
cron::Schedule::from_str(spec)
.map_err(|e| Error::app(format!("invalid cron schedule `{spec}`: {e}")))
}
pub(crate) fn parse_timezone(tz: &str) -> Result<chrono_tz::Tz> {
tz.parse::<chrono_tz::Tz>()
.map_err(|_| Error::app(format!("invalid cron timezone `{tz}`")))
}
fn next_cron_instant(
cron: &cron::Schedule,
tz: Option<&str>,
after: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
match tz {
Some(name) => {
let zone = name.parse::<chrono_tz::Tz>().ok()?;
cron.after(&after.with_timezone(&zone))
.next()
.map(|t| t.with_timezone(&Utc))
}
None => cron.after(&after).next(),
}
}
pub(crate) fn cron_ticks_between(
cron: &cron::Schedule,
tz: Option<&str>,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Vec<DateTime<Utc>> {
let mut out = Vec::new();
let mut cursor = start;
while let Some(next) = next_cron_instant(cron, tz, cursor) {
if next >= end {
break;
}
out.push(next);
cursor = next;
}
out
}
async fn backfill_ticks(
rt: &Arc<Runtime>,
schedule: &WorkflowSchedule,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<Vec<String>> {
let cron = parse_cron(&schedule.schedule)?;
let mut ids = Vec::new();
for instant in cron_ticks_between(&cron, schedule.cron_timezone.as_deref(), start, end) {
let (canonical, queued, created, id) = rt.persist_scheduled_tick(schedule, instant).await?;
rt.launch_scheduled_tick(schedule, canonical, queued, created, &id);
ids.push(id);
}
Ok(ids)
}
async fn schedule_reconciler(
rt: Arc<Runtime>,
shutting_down: Arc<AtomicBool>,
macro_schedules: Vec<WorkflowSchedule>,
) {
let mut installed: HashMap<String, InstalledSchedule> = HashMap::new();
loop {
if shutting_down.load(Ordering::Relaxed) {
for entry in installed.values() {
entry.stop.store(true, Ordering::Relaxed);
}
return;
}
let mut desired = match rt
.provider
.list_schedules(&ScheduleFilter {
statuses: vec![ScheduleStatus::Active],
..Default::default()
})
.await
{
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "schedule reconciler: failed to list schedules");
sleep_until_or_shutdown(SCHEDULE_RECONCILE_INTERVAL, &shutting_down).await;
continue;
}
};
let persisted: std::collections::HashSet<&str> =
desired.iter().map(|s| s.schedule_name.as_str()).collect();
let extra: Vec<WorkflowSchedule> = macro_schedules
.iter()
.filter(|m| !persisted.contains(m.schedule_name.as_str()))
.cloned()
.collect();
desired.extend(extra);
installed.retain(|name, entry| {
let keep = desired
.iter()
.any(|s| s.schedule_name == *name && s.schedule_id == entry.schedule_id);
if !keep {
entry.stop.store(true, Ordering::Relaxed);
}
keep
});
for schedule in desired {
if installed.contains_key(&schedule.schedule_name) {
continue;
}
if schedule.automatic_backfill {
if let Some(last) = schedule.last_fired_at {
let start = last + chrono::Duration::seconds(1);
let end = Utc::now();
if start < end {
if let Err(e) = backfill_ticks(&rt, &schedule, start, end).await {
tracing::error!(
schedule = %schedule.schedule_name, error = %e,
"automatic backfill failed"
);
}
}
}
}
let stop = Arc::new(AtomicBool::new(false));
installed.insert(
schedule.schedule_name.clone(),
InstalledSchedule {
schedule_id: schedule.schedule_id.clone(),
stop: stop.clone(),
},
);
tokio::spawn(schedule_fire_loop(
schedule,
rt.clone(),
stop,
shutting_down.clone(),
));
}
sleep_until_or_shutdown(SCHEDULE_RECONCILE_INTERVAL, &shutting_down).await;
}
}
async fn schedule_fire_loop(
schedule: WorkflowSchedule,
rt: Arc<Runtime>,
stop: Arc<AtomicBool>,
shutting_down: Arc<AtomicBool>,
) {
let cron = match parse_cron(&schedule.schedule) {
Ok(s) => s,
Err(e) => {
tracing::error!(
schedule = %schedule.schedule_name, spec = %schedule.schedule, error = %e,
"invalid cron schedule; not firing"
);
return;
}
};
let tz = schedule.cron_timezone.as_deref();
loop {
if stop.load(Ordering::Relaxed) || shutting_down.load(Ordering::Relaxed) {
return;
}
let Some(next) = next_cron_instant(&cron, tz, Utc::now()) else {
return;
};
let wait = (next - Utc::now()).to_std().unwrap_or(Duration::ZERO);
if !sleep_until_or_stop(wait, &stop, &shutting_down).await {
return;
}
match rt.persist_scheduled_tick(&schedule, next).await {
Ok((canonical, queued, created, id)) => {
fail::fail_point!("schedule_tick_after_persist", |_| {});
rt.launch_scheduled_tick(&schedule, canonical, queued, created, &id);
}
Err(e) => {
tracing::warn!(
schedule = %schedule.schedule_name, error = %e,
"failed to persist scheduled tick"
);
}
}
fail::fail_point!("schedule_tick_before_reschedule", |_| {});
let _ = rt
.provider
.set_schedule_last_fired(&schedule.schedule_name, next.timestamp_millis())
.await;
}
}
async fn sleep_until_or_shutdown(dur: Duration, shutting_down: &Arc<AtomicBool>) {
sleep_until_or_stop(dur, &Arc::new(AtomicBool::new(false)), shutting_down).await;
}
async fn sleep_until_or_stop(
dur: Duration,
stop: &Arc<AtomicBool>,
shutting_down: &Arc<AtomicBool>,
) -> bool {
let deadline = std::time::Instant::now() + dur;
loop {
if stop.load(Ordering::Relaxed) || shutting_down.load(Ordering::Relaxed) {
return false;
}
let now = std::time::Instant::now();
if now >= deadline {
return true;
}
tokio::time::sleep((deadline - now).min(Duration::from_millis(100))).await;
}
}
#[cfg(test)]
mod config_tests {
use super::*;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct EnvVar(&'static str, Option<String>);
impl EnvVar {
fn set(key: &'static str, value: &str) -> Self {
let prior = std::env::var(key).ok();
std::env::set_var(key, value);
EnvVar(key, prior)
}
}
impl Drop for EnvVar {
fn drop(&mut self) {
match &self.1 {
Some(v) => std::env::set_var(self.0, v),
None => std::env::remove_var(self.0),
}
}
}
#[test]
fn defaults_are_binary_hash_and_local() {
let _g = ENV_LOCK.lock().unwrap();
let cfg = EngineConfig::default();
let ver = cfg.resolve_app_version();
assert_eq!(ver.len(), 64, "sha-256 hex of the executable: {ver}");
assert!(ver.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(cfg.resolve_executor_id(), "local");
}
#[test]
fn explicit_config_wins_over_defaults() {
let _g = ENV_LOCK.lock().unwrap();
let cfg = EngineConfig::default()
.app_version("9.9.9")
.executor_id("exec-7");
assert_eq!(cfg.resolve_app_version(), "9.9.9");
assert_eq!(cfg.resolve_executor_id(), "exec-7");
}
#[test]
fn env_overrides_explicit_config() {
let _g = ENV_LOCK.lock().unwrap();
let _v = EnvVar::set("DBOS__APPVERSION", "env-ver");
let _e = EnvVar::set("DBOS__VMID", "env-vm");
let cfg = EngineConfig::default()
.app_version("9.9.9")
.executor_id("exec-7");
assert_eq!(cfg.resolve_app_version(), "env-ver");
assert_eq!(cfg.resolve_executor_id(), "env-vm");
}
#[test]
fn empty_env_is_ignored() {
let _g = ENV_LOCK.lock().unwrap();
let _v = EnvVar::set("DBOS__APPVERSION", "");
let _e = EnvVar::set("DBOS__VMID", "");
let cfg = EngineConfig::default().app_version("9.9.9");
assert_eq!(cfg.resolve_app_version(), "9.9.9");
assert_eq!(cfg.resolve_executor_id(), "local");
}
}