Skip to content

Zart is in active development — breaking API changes may occur despite our best efforts to keep contracts stable.

DurableExecution Trait

DurableExecution is the trait you implement to define a durable workflow. Zart calls run each time a worker claims your execution. Completed steps are skipped automatically — only the next pending step executes.

#[async_trait]
pub trait DurableExecution: Send + Sync {
type Data: serde::de::DeserializeOwned + Send + Sync;
type Output: serde::Serialize + Send + Sync;
async fn run(&self, data: Self::Data) -> Result<Self::Output, TaskError>;
/// Retry the full execution this many times on task-level failure. Default: 0.
fn max_retries(&self) -> usize { 0 }
/// Wall-clock timeout for the entire execution. Default: None (no deadline).
fn timeout(&self) -> Option<Duration> { None }
}

Both Data and Output must implement serde traits — Zart serializes them to the database.

The run method receives the deserialized payload. All workflow operations are available as free functions under zart:: — there is no context object to thread through your code.

When timeout() returns Some(duration), the framework computes a deadline as scheduled_at + duration when the execution is first started. The worker checks this deadline before dispatching each task. If the deadline has passed, the worker invokes on_failure(ExecutionDeadlineExceeded) instead of executing the task.

Register handlers using the fluent builder API. The string name is how you reference the workflow when scheduling.

let pg = zart::PgBackend::new(pool);
let worker = zart::WorkerBuilder::from_backend(&pg)
.register_durable_task("onboarding", OnboardingTask)
.register_durable_task("checkout", CheckoutTask)
.config(zart::WorkerConfig::default())
.build();

Alternatively, build a DurableRegistry separately and pass it to the builder:

let mut registry = DurableRegistry::new();
registry.register("onboarding", OnboardingTask);
registry.register("checkout", CheckoutTask);
let worker = zart::WorkerBuilder::from_backend(&pg)
.durable_registry(registry)
.config(zart::WorkerConfig::default())
.build();

Steps are defined with #[zart_step] or by manually implementing the ZartStep trait. Each step’s result is persisted to the database before the workflow moves on.

Call step builder functions directly with .await:

// Macro-generated steps are called like ordinary async functions
let customer_id: String = create_customer(&data.email).await?;
let _: () = send_welcome(&data.email).await?;
  • The step name is derived from #[zart_step("name")] and must be unique within the execution.
  • The return type must implement Serialize + DeserializeOwned.
  • If a result is already stored for this step, the step is skipped and the cached result is returned.

You can also call zart::step(s) explicitly when working with manually constructed step structs:

zart::step(SendEmailStep { email: data.email.clone() }).await?;

Override retry_config() in your ZartStep impl, or use the retry attribute on #[zart_step]:

#[zart_step("place-order", retry = "exponential(5, 1s)")]
async fn place_order(card: &str, amount: i64) -> Result<String, StepError> {
payments_api.charge(card, amount).await
}

See ZartStep for all available configuration options.

Use these functions to pause a workflow for a fixed duration or until a specific time. The worker releases the task during the wait — it does not hold a database connection or thread.

Each sleep call requires a unique, stable name — the database key used to skip the sleep on replay.

// Pause for 30 minutes, then continue
zart::sleep("post-email-wait", Duration::from_secs(1800)).await?;
// Resume at a specific timestamp
zart::sleep_until("wait-for-billing", next_billing_date).await?;

zart::wait_for_event suspends the workflow until an external signal arrives — a human approval, a webhook callback, or a message from another system.

// Suspend until "manager-approval" event is delivered, or fail after 24h
let decision: ApprovalPayload = zart::wait_for_event(
"manager-approval",
Some(Duration::from_secs(86_400)),
).await?;

Deliver the event from anywhere using DurableScheduler::offer_event() or via the HTTP API:

Terminal window
POST /api/v1/events/{execution_id}/manager-approval
{"approved": true, "reviewer": "bob@example.com"}

Returns Err(TaskError::EventTimeout) if the timeout elapses before the event arrives. See Wait for Event for full patterns.

Capture variables let you durably persist a synchronous, pure value without defining a full step. Ideal for timestamps, environment reads, or generated IDs:

let started_at = zart::capture("started-at", || chrono::Utc::now()).await?;
let user_tz = zart::capture("user-tz", || env::var("TZ").unwrap_or_default()).await?;

Or use the built-in now() helper for timestamps:

let started_at = zart::now("started-at").await?;

On first run the expression is evaluated and persisted. On replay the cached value is returned — the expression is never re-evaluated. See Capture Variables for full details and when to use captures vs regular steps.

Fan out independent work across multiple concurrent sub-tasks. Each zart::schedule call creates a separate row in the scheduler — sub-tasks can run on different workers simultaneously.

// Schedule three steps to run in parallel
let h1 = zart::schedule(send_email(&user.email));
let h2 = zart::schedule(setup_billing(customer_id));
let h3 = zart::schedule(provision(&user.user_id));
// Durably wait for all three — survives restarts
let results = zart::wait(vec![h1, h2, h3]).await?;

See Parallel Steps for error handling and dynamic fan-out patterns.

Returns a read-only snapshot of the current execution. Callable from the handler body and from inside a step body.

let info = zart::context();
println!("execution: {}", info.execution_id);
println!("attempt: {}", info.current_attempt); // 0-indexed
println!("is_retry: {}", info.is_retry());

ExecutionInfo fields:

FieldTypeDescription
execution_idStringUnique identifier of the running execution
task_nameStringRegistered name of the task handler
dataserde_json::ValueOriginal JSON payload (read-only)
current_attemptusize0 on first attempt, 1 on first retry
max_retriesOption<usize>Configured retry limit
// Build an idempotency key for an external API call
let key = format!("charge-{}", zart::context().execution_id);
stripe.charge_with_idempotency_key(&card, amount, &key).await?;

Controls retry behaviour for steps (via ZartStep::retry_config()). All three constructors share the same fields: number of attempts, initial delay, and backoff multiplier.

ConstructorDelay between retries
RetryConfig::none()No retries — fail immediately
RetryConfig::fixed(n, delay)Constant delay between each attempt
RetryConfig::exponential(n, initial)Doubles each time: initial, , , …
// No retries (idempotent steps that must not be duplicated)
RetryConfig::none()
// 3 retries, 5 seconds apart
RetryConfig::fixed(3, Duration::from_secs(5))
// 5 retries: 1s, 2s, 4s, 8s, 16s
RetryConfig::exponential(5, Duration::from_secs(1))

A five-step onboarding workflow combining steps, retries, and an event wait:

use zart::prelude::*;
use zart::{zart_durable, zart_step};
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Deserialize)]
pub struct OnboardingData {
pub user_id: String,
pub email: String,
}
#[derive(Debug, Serialize)]
pub struct OnboardingResult {
pub customer_id: String,
}
// ── Step definitions ─────────────────────────────────────────────────────────
#[zart_step("send-welcome-email")]
async fn send_welcome_email(email: &str) -> Result<(), StepError> {
mailer.send(email, "Welcome!").await
}
#[zart_step("create-stripe-customer", retry = "exponential(3, 2s)")]
async fn create_stripe_customer(email: &str) -> Result<String, StepError> {
stripe.create_customer(email).await
}
#[zart_step("provision-resource")]
async fn provision_resource(user_id: &str, customer_id: &str) -> Result<(), StepError> {
provision_aws_resources(user_id, customer_id).await
}
#[zart_step("activate-account")]
async fn activate_account(user_id: &str) -> Result<(), StepError> {
activate(user_id).await
}
// ── Durable handler ──────────────────────────────────────────────────────────
#[zart_durable("onboarding", timeout = "50h")]
async fn onboarding(data: OnboardingData) -> Result<OnboardingResult, TaskError> {
// Step 1 — send welcome email
send_welcome_email(&data.email).await?;
// Step 2 — create Stripe customer (retry 3× with exponential backoff)
let customer_id: String = create_stripe_customer(&data.email).await?;
// Step 3 — provision cloud resources
provision_resource(&data.user_id, &customer_id).await?;
// Step 4 — wait up to 48h for email verification
let _: VerificationPayload = zart::wait_for_event(
"email-verified",
Some(Duration::from_secs(172_800)),
).await?;
// Step 5 — activate account
activate_account(&data.user_id).await?;
Ok(OnboardingResult { customer_id })
}