pub mod cron;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
pub use cron::{
ConcurrencyPolicy, CreateCronJobRequest, CronJob, CronJobResponse, CronJobStatus, CronState,
cron_jobs_router, cron_jobs_router_with_state, start_cron_scheduler, validate_cron_expression,
};
pub type WorkflowState = HashMap<String, Value>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RunStatus {
Queued,
Running,
Completed,
Failed,
Cancelled,
}
#[derive(Debug, Clone)]
pub struct BackgroundRun {
pub run_id: String,
pub workflow_id: String,
pub status: RunStatus,
pub input: WorkflowState,
pub result: Option<Value>,
pub error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub timeout: Option<Duration>,
pub max_retries: u32,
pub retry_count: u32,
pub cancel_token: CancellationToken,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubmitRunRequest {
pub workflow_id: String,
pub input: WorkflowState,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub max_retries: Option<u32>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubmitRunResponse {
pub run_id: String,
pub status: RunStatus,
pub created_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RunStatusResponse {
pub run_id: String,
pub status: RunStatus,
pub created_at: String,
pub updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retries_remaining: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunRetention {
pub max_finished: Option<usize>,
}
impl Default for RunRetention {
fn default() -> Self {
Self { max_finished: Some(1000) }
}
}
impl RunRetention {
pub fn keep_finished(count: usize) -> Self {
Self { max_finished: Some(count) }
}
pub fn unlimited() -> Self {
Self { max_finished: None }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PersistedRun {
pub run_id: String,
pub workflow_id: String,
pub status: RunStatus,
pub input: WorkflowState,
pub result: Option<Value>,
pub error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub retry_count: u32,
}
impl From<&BackgroundRun> for PersistedRun {
fn from(run: &BackgroundRun) -> Self {
Self {
run_id: run.run_id.clone(),
workflow_id: run.workflow_id.clone(),
status: run.status,
input: run.input.clone(),
result: run.result.clone(),
error: run.error.clone(),
created_at: run.created_at,
updated_at: run.updated_at,
retry_count: run.retry_count,
}
}
}
#[async_trait::async_trait]
pub trait RunPersistence: Send + Sync {
async fn upsert(&self, run: &PersistedRun) -> Result<(), String>;
async fn load_all(&self) -> Result<Vec<PersistedRun>, String>;
async fn remove(&self, run_ids: &[String]) -> Result<(), String>;
}
pub struct FileRunPersistence {
path: std::path::PathBuf,
lock: tokio::sync::Mutex<()>,
}
impl FileRunPersistence {
pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
Self { path: path.into(), lock: tokio::sync::Mutex::new(()) }
}
fn write_unlocked(&self, runs: &[PersistedRun]) -> Result<(), String> {
let text = serde_json::to_string_pretty(runs).map_err(|e| e.to_string())?;
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let temporary = self.path.with_extension("json.tmp");
std::fs::write(&temporary, text).map_err(|e| e.to_string())?;
std::fs::rename(&temporary, &self.path).map_err(|e| e.to_string())
}
fn read_unlocked(&self) -> Result<Vec<PersistedRun>, String> {
match std::fs::read_to_string(&self.path) {
Ok(text) => serde_json::from_str(&text).map_err(|e| e.to_string()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(error.to_string()),
}
}
}
#[async_trait::async_trait]
impl RunPersistence for FileRunPersistence {
async fn upsert(&self, run: &PersistedRun) -> Result<(), String> {
let _guard = self.lock.lock().await;
let mut runs = self.read_unlocked()?;
match runs.iter_mut().find(|existing| existing.run_id == run.run_id) {
Some(existing) => *existing = run.clone(),
None => runs.push(run.clone()),
}
self.write_unlocked(&runs)
}
async fn load_all(&self) -> Result<Vec<PersistedRun>, String> {
let _guard = self.lock.lock().await;
self.read_unlocked()
}
async fn remove(&self, run_ids: &[String]) -> Result<(), String> {
if run_ids.is_empty() {
return Ok(());
}
let _guard = self.lock.lock().await;
let mut runs = self.read_unlocked()?;
runs.retain(|run| !run_ids.contains(&run.run_id));
self.write_unlocked(&runs)
}
}
#[derive(Clone, Default)]
pub struct RunStore {
runs: Arc<RwLock<HashMap<String, BackgroundRun>>>,
persistence: Option<Arc<dyn RunPersistence>>,
retention: RunRetention,
}
impl std::fmt::Debug for RunStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RunStore")
.field("persistent", &self.persistence.is_some())
.finish_non_exhaustive()
}
}
impl RunStore {
pub fn new() -> Self {
Self {
runs: Arc::new(RwLock::new(HashMap::new())),
persistence: None,
retention: RunRetention::default(),
}
}
pub fn with_retention(mut self, retention: RunRetention) -> Self {
self.retention = retention;
self
}
async fn evict_finished(&self) {
let Some(max) = self.retention.max_finished else { return };
let mut runs = self.runs.write().await;
let mut finished: Vec<(String, chrono::DateTime<Utc>)> = runs
.values()
.filter(|run| {
matches!(
run.status,
RunStatus::Completed | RunStatus::Failed | RunStatus::Cancelled
)
})
.map(|run| (run.run_id.clone(), run.updated_at))
.collect();
if finished.len() <= max {
return;
}
finished.sort_by_key(|(_, updated)| *updated);
let excess = finished.len() - max;
let discarded: Vec<String> =
finished.into_iter().take(excess).map(|(run_id, _)| run_id).collect();
for run_id in &discarded {
runs.remove(run_id);
}
drop(runs);
if let Some(backend) = &self.persistence
&& let Err(error) = backend.remove(&discarded).await
{
tracing::warn!(error = %error, "could not discard run records");
}
}
pub fn with_persistence(mut self, persistence: Arc<dyn RunPersistence>) -> Self {
self.persistence = Some(persistence);
self
}
async fn persist(&self, run: &BackgroundRun) {
self.persist_record(&PersistedRun::from(run)).await;
}
async fn persist_record(&self, record: &PersistedRun) {
if let Some(backend) = &self.persistence
&& let Err(error) = backend.upsert(record).await
{
tracing::warn!(run.id = %record.run_id, error = %error, "could not record run");
}
}
pub async fn restore(&self) -> Result<Vec<String>, String> {
let Some(backend) = &self.persistence else { return Ok(Vec::new()) };
let recorded = backend.load_all().await?;
let mut interrupted = Vec::new();
let mut runs = self.runs.write().await;
for record in recorded {
let was_running = matches!(record.status, RunStatus::Running | RunStatus::Queued);
let mut run = BackgroundRun {
run_id: record.run_id.clone(),
workflow_id: record.workflow_id,
status: record.status,
input: record.input,
result: record.result,
error: record.error,
created_at: record.created_at,
updated_at: record.updated_at,
timeout: None,
max_retries: 0,
retry_count: record.retry_count,
cancel_token: CancellationToken::new(),
};
if was_running {
run.status = RunStatus::Failed;
run.error = Some("the process stopped while this run was in flight".to_string());
interrupted.push(record.run_id.clone());
}
runs.insert(record.run_id, run);
}
Ok(interrupted)
}
pub async fn insert(&self, run: BackgroundRun) {
let run_for_record = run.clone();
self.runs.write().await.insert(run.run_id.clone(), run);
self.persist(&run_for_record).await;
}
pub async fn get(&self, run_id: &str) -> Option<BackgroundRun> {
self.runs.read().await.get(run_id).cloned()
}
pub async fn update_status(&self, run_id: &str, status: RunStatus) {
let record = {
let mut runs = self.runs.write().await;
let Some(run) = runs.get_mut(run_id) else { return };
run.status = status;
run.updated_at = Utc::now();
PersistedRun::from(&*run)
};
self.persist_record(&record).await;
}
pub async fn set_completed(&self, run_id: &str, result: Value) {
let record = {
let mut runs = self.runs.write().await;
let Some(run) = runs.get_mut(run_id) else { return };
run.status = RunStatus::Completed;
run.result = Some(result);
run.updated_at = Utc::now();
PersistedRun::from(&*run)
};
self.persist_record(&record).await;
self.evict_finished().await;
}
pub async fn set_failed(&self, run_id: &str, error: String) {
let record = {
let mut runs = self.runs.write().await;
let Some(run) = runs.get_mut(run_id) else { return };
run.status = RunStatus::Failed;
run.error = Some(error);
run.updated_at = Utc::now();
PersistedRun::from(&*run)
};
self.persist_record(&record).await;
self.evict_finished().await;
}
pub async fn retry(&self, run_id: &str) -> bool {
if let Some(run) = self.runs.write().await.get_mut(run_id)
&& run.retry_count < run.max_retries
{
run.retry_count += 1;
run.status = RunStatus::Queued;
run.error = None;
run.updated_at = Utc::now();
return true;
}
false
}
}
#[async_trait::async_trait]
pub trait WorkflowExecutor: Send + Sync {
fn has_workflow(&self, workflow_id: &str) -> bool;
async fn execute(
&self,
workflow_id: &str,
input: WorkflowState,
cancel_token: CancellationToken,
) -> std::result::Result<Value, String>;
}
#[derive(Default)]
pub struct WorkflowRegistry {
workflows: std::collections::HashMap<String, Arc<BoxedWorkflow>>,
}
type BoxedWorkflow = dyn Fn(
WorkflowState,
CancellationToken,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = std::result::Result<Value, String>> + Send>,
> + Send
+ Sync;
impl WorkflowRegistry {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn register<F, Fut>(mut self, workflow_id: impl Into<String>, workflow: F) -> Self
where
F: Fn(WorkflowState, CancellationToken) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = std::result::Result<Value, String>> + Send + 'static,
{
self.workflows.insert(
workflow_id.into(),
Arc::new(move |input, cancel| Box::pin(workflow(input, cancel))),
);
self
}
}
#[async_trait::async_trait]
impl WorkflowExecutor for WorkflowRegistry {
fn has_workflow(&self, workflow_id: &str) -> bool {
self.workflows.contains_key(workflow_id)
}
async fn execute(
&self,
workflow_id: &str,
input: WorkflowState,
cancel_token: CancellationToken,
) -> std::result::Result<Value, String> {
match self.workflows.get(workflow_id) {
Some(workflow) => workflow(input, cancel_token).await,
None => Err(format!("workflow '{workflow_id}' is not registered")),
}
}
}
#[derive(Clone)]
pub struct BackgroundRunner {
store: RunStore,
executor: Option<Arc<dyn WorkflowExecutor>>,
}
impl std::fmt::Debug for BackgroundRunner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BackgroundRunner")
.field("store", &self.store)
.field("has_executor", &self.executor.is_some())
.finish()
}
}
impl BackgroundRunner {
pub fn new(store: RunStore) -> Self {
Self { store, executor: None }
}
pub fn executor(&self) -> Option<&Arc<dyn WorkflowExecutor>> {
self.executor.as_ref()
}
#[must_use]
pub fn with_executor(mut self, executor: Arc<dyn WorkflowExecutor>) -> Self {
self.executor = Some(executor);
self
}
pub fn execute(&self, run_id: String) {
let store = self.store.clone();
let executor = self.executor.clone();
tokio::spawn(async move {
let run = match store.get(&run_id).await {
Some(r) => r,
None => return,
};
let cancel_token = run.cancel_token.clone();
let timeout_duration = run.timeout;
store.update_status(&run_id, RunStatus::Running).await;
let result = Self::run_with_timeout(
executor.as_ref(),
&run.workflow_id,
run.input.clone(),
timeout_duration,
&cancel_token,
)
.await;
match result {
RunOutcome::Completed(value) => {
store.set_completed(&run_id, value).await;
}
RunOutcome::Failed(error) => {
if store.retry(&run_id).await {
let store_clone = store.clone();
let run_id_clone = run_id.clone();
let executor_clone = executor.clone();
tokio::spawn(async move {
let mut runner = BackgroundRunner::new(store_clone);
if let Some(executor) = executor_clone {
runner = runner.with_executor(executor);
}
runner.execute(run_id_clone);
});
} else {
store.set_failed(&run_id, error).await;
}
}
RunOutcome::Cancelled => {
store.update_status(&run_id, RunStatus::Cancelled).await;
}
RunOutcome::TimedOut => {
store.set_failed(&run_id, "run timed out".to_string()).await;
}
}
});
}
async fn run_with_timeout(
executor: Option<&Arc<dyn WorkflowExecutor>>,
workflow_id: &str,
input: WorkflowState,
timeout_duration: Option<Duration>,
cancel_token: &CancellationToken,
) -> RunOutcome {
let work = async {
if cancel_token.is_cancelled() {
return RunOutcome::Cancelled;
}
let Some(executor) = executor else {
return RunOutcome::Failed(format!(
"no workflow executor is configured, so workflow '{workflow_id}' cannot run"
));
};
match executor.execute(workflow_id, input, cancel_token.clone()).await {
Ok(value) => RunOutcome::Completed(value),
Err(error) => RunOutcome::Failed(error),
}
};
match timeout_duration {
Some(duration) => {
tokio::select! {
_ = cancel_token.cancelled() => RunOutcome::Cancelled,
result = tokio::time::timeout(duration, work) => {
match result {
Ok(outcome) => outcome,
Err(_) => RunOutcome::TimedOut,
}
}
}
}
None => {
tokio::select! {
_ = cancel_token.cancelled() => RunOutcome::Cancelled,
outcome = work => outcome,
}
}
}
}
}
#[derive(Debug)]
#[allow(dead_code)]
enum RunOutcome {
Completed(Value),
Failed(String),
Cancelled,
TimedOut,
}
#[derive(Debug, Clone)]
pub struct BackgroundState {
pub store: RunStore,
pub runner: BackgroundRunner,
}
impl BackgroundState {
#[must_use]
pub fn with_executor(mut self, executor: Arc<dyn WorkflowExecutor>) -> Self {
self.runner = self.runner.with_executor(executor);
self
}
pub fn new() -> Self {
let store = RunStore::new();
let runner = BackgroundRunner::new(store.clone());
Self { store, runner }
}
}
impl Default for BackgroundState {
fn default() -> Self {
Self::new()
}
}
async fn submit_run(
State(state): State<BackgroundState>,
Json(request): Json<SubmitRunRequest>,
) -> impl IntoResponse {
if let Some(executor) = state.runner.executor()
&& !executor.has_workflow(&request.workflow_id)
{
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("unknown workflow '{}'", request.workflow_id)
})),
)
.into_response();
}
let run_id = uuid::Uuid::new_v4().to_string();
let now = Utc::now();
let run = BackgroundRun {
run_id: run_id.clone(),
workflow_id: request.workflow_id,
status: RunStatus::Queued,
input: request.input,
result: None,
error: None,
created_at: now,
updated_at: now,
timeout: request.timeout_secs.map(Duration::from_secs),
max_retries: request.max_retries.unwrap_or(0),
retry_count: 0,
cancel_token: CancellationToken::new(),
};
state.store.insert(run).await;
state.runner.execute(run_id.clone());
let response =
SubmitRunResponse { run_id, status: RunStatus::Queued, created_at: now.to_rfc3339() };
(StatusCode::CREATED, Json(response)).into_response()
}
async fn get_run_status(
State(state): State<BackgroundState>,
Path(run_id): Path<String>,
) -> impl IntoResponse {
match state.store.get(&run_id).await {
Some(run) => {
let retries_remaining = if run.max_retries > 0 {
Some(run.max_retries.saturating_sub(run.retry_count))
} else {
None
};
let retry_count = if run.max_retries > 0 { Some(run.retry_count) } else { None };
let response = RunStatusResponse {
run_id: run.run_id,
status: run.status,
created_at: run.created_at.to_rfc3339(),
updated_at: run.updated_at.to_rfc3339(),
result: run.result,
error: run.error,
retry_count,
retries_remaining,
};
(StatusCode::OK, Json(response)).into_response()
}
None => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "run not found" })))
.into_response(),
}
}
async fn cancel_run(
State(state): State<BackgroundState>,
Path(run_id): Path<String>,
) -> impl IntoResponse {
match state.store.get(&run_id).await {
Some(run) => {
match run.status {
RunStatus::Completed | RunStatus::Failed | RunStatus::Cancelled => {
let response = RunStatusResponse {
run_id: run.run_id,
status: run.status,
created_at: run.created_at.to_rfc3339(),
updated_at: run.updated_at.to_rfc3339(),
result: run.result,
error: run.error,
retry_count: if run.max_retries > 0 { Some(run.retry_count) } else { None },
retries_remaining: if run.max_retries > 0 {
Some(run.max_retries.saturating_sub(run.retry_count))
} else {
None
},
};
(StatusCode::OK, Json(response)).into_response()
}
RunStatus::Queued | RunStatus::Running => {
run.cancel_token.cancel();
state.store.update_status(&run_id, RunStatus::Cancelled).await;
let updated = state.store.get(&run_id).await.unwrap();
let response = RunStatusResponse {
run_id: updated.run_id,
status: updated.status,
created_at: updated.created_at.to_rfc3339(),
updated_at: updated.updated_at.to_rfc3339(),
result: updated.result,
error: updated.error,
retry_count: if updated.max_retries > 0 {
Some(updated.retry_count)
} else {
None
},
retries_remaining: if updated.max_retries > 0 {
Some(updated.max_retries.saturating_sub(updated.retry_count))
} else {
None
},
};
(StatusCode::OK, Json(response)).into_response()
}
}
}
None => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "run not found" })))
.into_response(),
}
}
pub fn background_runs_router() -> Router {
let state = BackgroundState::new();
background_runs_router_with_state(state)
}
pub fn background_runs_router_with_state(state: BackgroundState) -> Router {
Router::new()
.route("/runs", post(submit_run))
.route("/runs/{run_id}", get(get_run_status).delete(cancel_run))
.with_state(state)
}