Notice: This is a "preview" version.
Duroxide is a lightweight, embeddable durable execution runtime for Rust.
Write ordinary async Rust. Duroxide makes it durable: your code keeps
running across process crashes, restarts, and deployments. A workflow that
waits 30 days looks exactly like one that waits 30 milliseconds β and if the
process dies in the middle, it resumes right where it left off, without
re-running the work it already finished.
Inspired by the Durable Task Framework and Temporal.
- Durable by default β every step is recorded; crashes resume from the last completed step.
- Plain async Rust β orchestrate with
.await, control flow, and error handling you already know. - Embeddable β runs in-process on Tokio. No separate server to operate.
- Storage-agnostic β a
Providertrait backs persistence; a SQLite provider (in-memory or file) is built in.
- Function chaining β sequential steps where each depends on the last.
- Fan-out / fan-in β run many activities in parallel, then aggregate deterministically.
- Human-in-the-loop β wait for approvals, callbacks, or webhooks, then resume.
- Durable timers β sleep for minutes, hours, or days without holding a thread.
- Saga compensation β roll back prior steps on failure.
- Built-in retries β configurable backoff and per-attempt timeouts.
- Cancellation β in-flight activities receive cooperative cancellation signals.
- Worker specialization β route activities to dedicated pools with tags (e.g.
gpu). - Durable KV β per-instance key/value state that survives replay.
[dependencies]
duroxide = { version = "0.1", features = ["sqlite"] } # With the bundled SQLite provider
# OR
duroxide = "0.1" # Core only β bring your own Provideruse std::sync::Arc;
use duroxide::{ActivityContext, Client, OrchestrationContext, OrchestrationRegistry, OrchestrationStatus};
use duroxide::runtime::{self};
use duroxide::runtime::registry::ActivityRegistry;
use duroxide::providers::sqlite::SqliteProvider;
# #[tokio::main]
# async fn main() -> Result<(), Box<dyn std::error::Error>> {
let store = Arc::new(SqliteProvider::new("sqlite:./data.db", None).await?);
// An activity does the real work (I/O, HTTP, DB β anything).
let activities = ActivityRegistry::builder()
.register("Hello", |_ctx: ActivityContext, name: String| async move {
Ok(format!("Hello, {name}!"))
})
.build();
// An orchestration coordinates activities deterministically.
let orchestrations = OrchestrationRegistry::builder()
.register("HelloWorld", |ctx: OrchestrationContext, name: String| async move {
let result = ctx.schedule_activity("Hello", name).await?;
Ok::<_, String>(result)
})
.build();
let rt = runtime::Runtime::start_with_store(store.clone(), activities, orchestrations).await;
let client = Client::new(store);
client.start_orchestration("inst-1", "HelloWorld", "Rust").await?;
match client.wait_for_orchestration("inst-1", std::time::Duration::from_secs(5)).await? {
OrchestrationStatus::Completed { output } => assert_eq!(output, "Hello, Rust!"),
other => panic!("unexpected: {other:?}"),
}
rt.shutdown(None).await;
# Ok(())
# }Each completed step is durably recorded, so a restart replays history and resumes from exactly where it stopped.
use duroxide::OrchestrationContext;
async fn fulfill_order(ctx: OrchestrationContext, order_id: String) -> Result<String, String> {
// Step 1: charge the card. When this completes, the result is written to history.
let receipt = ctx.schedule_activity("ChargeCard", order_id.clone()).await?;
// π₯ If the process crashes HERE, Duroxide replays on restart:
// `ChargeCard` is NOT charged again β its recorded result is returned
// instantly and execution continues from this exact line.
// Step 2: wait for the warehouse. This can take hours or days; no thread is held.
let _ = ctx.schedule_wait("WarehouseShipped").await;
// π₯ Crash anywhere in this wait? On restart we resume still waiting β
// the timer/event state is durable.
// Step 3: notify the customer.
ctx.schedule_activity("SendShippingEmail", order_id).await?;
Ok(receipt)
}use duroxide::OrchestrationContext;
async fn fanout(ctx: OrchestrationContext) -> Vec<String> {
let f1 = ctx.schedule_activity("Greet", "Gabbar");
let f2 = ctx.schedule_activity("Greet", "Samba");
// join resolves deterministically by history order, not polling order.
ctx.join(vec![f1, f2])
.await
.into_iter()
.map(|o| o.unwrap_or_else(|e| panic!("activity failed: {e}")))
.collect()
}use duroxide::{Either2, OrchestrationContext};
async fn wait_with_timeout(ctx: OrchestrationContext) -> String {
let timer = ctx.schedule_timer(std::time::Duration::from_secs(60));
let event = ctx.schedule_wait("Approval");
// Use ctx.select2 β NOT tokio::select! β so the outcome is replay-safe.
match ctx.select2(timer, event).await {
Either2::First(()) => "timed out".to_string(),
Either2::Second(data) => data,
}
}use duroxide::OrchestrationContext;
async fn with_recovery(ctx: OrchestrationContext) -> String {
match ctx.schedule_activity("Fragile", "input").await {
Ok(v) => v,
Err(e) => {
ctx.trace_warn(format!("fragile failed: {e}"));
ctx.schedule_activity("Compensate", "").await.unwrap()
}
}
}Duroxide runs each orchestration turn by turn. Every operation gets a
correlation id; scheduling is recorded as a history event (e.g.
ActivityScheduled) and completions are matched back by id (e.g.
ActivityCompleted). On restart, the runtime replays that history to
rebuild in-memory state: completed steps return their recorded results without
re-executing, and the orchestration continues from the first unfinished step.
This is why orchestrations must be deterministic β they coordinate, they don't do I/O. Activities are where side effects happen, and they run at most once per logical step. A few consequences worth knowing:
- Use
ctx.join/ctx.select2(nottokio::join!/tokio::select!) so concurrency resolves by history order, not wall-clock polling. - Use
ctx.schedule_timer(),ctx.new_guid(),ctx.utcnow()instead ofstd::time,rand, orUuid::new_v4()directly.
π For the full story β how futures are made durable, the replay algorithm step by step, and nondeterminism detection β read Durable Futures Internals.
Several related projects share Duroxide's durable-execution model. Pick the one that fits how you want to author and host your workflows:
- pg_durable β PostgreSQL extension. Use this when you want durable pipelines and functions directly in PostgreSQL, with no other moving parts.
- duroxide (this repo) β Rust durable-execution runtime. Use this when you want to author workflows in Rust and embed the runtime in your service. Multiple storage providers are available (SQLite built-in, PostgreSQL via duroxide-pg, or bring your own).
- duroxide-python β Python SDK over the duroxide runtime. Use this when you want to author workflows in Python.
- duroxide-node β Node.js / TypeScript SDK over the duroxide runtime. Use this when you want to author workflows in JavaScript / TypeScript.
- duroxide-pg β PostgreSQL provider for the duroxide runtime. Plug this into duroxide / duroxide-python / duroxide-node when you want PostgreSQL as the durable store.
- Orchestration Guide β the complete guide to writing workflows.
- Durable Futures Internals β how replay and durability work under the hood.
- Provider Implementation / Provider Testing β build and test a custom storage backend.
- Observability Guide β structured logging and metrics.
- AI Skills β context files for AI assistants (Copilot, Cursor, etc.).
- Examples β
cargo run --example hello_world, plus more inexamples/andtests/e2e_samples.rs.
cargo build # Build
cargo test --all -- --nocapture # Run all tests
./run-stress-tests.sh # Stress tests (see STRESS_TEST_MONITORING.md)See CHANGELOG.md for release notes and CONTRIBUTING.md to get involved.
