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.
The Trait
Section titled “The Trait”#[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.
Execution Timeout
Section titled “Execution Timeout”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.
Registration
Section titled “Registration”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();Core Steps
Section titled “Core Steps”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.
Sequential Steps
Section titled “Sequential Steps”Call step builder functions directly with .await:
// Macro-generated steps are called like ordinary async functionslet 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?;Steps with Retry
Section titled “Steps with Retry”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.
Timing
Section titled “Timing”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.
zart::sleep()
Section titled “zart::sleep()”// Pause for 30 minutes, then continuezart::sleep("post-email-wait", Duration::from_secs(1800)).await?;zart::sleep_until()
Section titled “zart::sleep_until()”// Resume at a specific timestampzart::sleep_until("wait-for-billing", next_billing_date).await?;Events
Section titled “Events”zart::wait_for_event suspends the workflow until an external signal arrives — a human approval, a webhook callback, or a message from another system.
zart::wait_for_event()
Section titled “zart::wait_for_event()”// Suspend until "manager-approval" event is delivered, or fail after 24hlet 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:
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 Values
Section titled “Capture Values”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.
Parallel Execution
Section titled “Parallel Execution”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.
zart::schedule() / zart::wait()
Section titled “zart::schedule() / zart::wait()”// Schedule three steps to run in parallellet 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 restartslet results = zart::wait(vec![h1, h2, h3]).await?;See Parallel Steps for error handling and dynamic fan-out patterns.
Execution Metadata
Section titled “Execution Metadata”zart::context()
Section titled “zart::context()”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-indexedprintln!("is_retry: {}", info.is_retry());ExecutionInfo fields:
| Field | Type | Description |
|---|---|---|
execution_id | String | Unique identifier of the running execution |
task_name | String | Registered name of the task handler |
data | serde_json::Value | Original JSON payload (read-only) |
current_attempt | usize | 0 on first attempt, 1 on first retry |
max_retries | Option<usize> | Configured retry limit |
// Build an idempotency key for an external API calllet key = format!("charge-{}", zart::context().execution_id);stripe.charge_with_idempotency_key(&card, amount, &key).await?;RetryConfig
Section titled “RetryConfig”Controls retry behaviour for steps (via ZartStep::retry_config()). All three constructors share the same fields: number of attempts, initial delay, and backoff multiplier.
| Constructor | Delay 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, 2×, 4×, … |
// No retries (idempotent steps that must not be duplicated)RetryConfig::none()
// 3 retries, 5 seconds apartRetryConfig::fixed(3, Duration::from_secs(5))
// 5 retries: 1s, 2s, 4s, 8s, 16sRetryConfig::exponential(5, Duration::from_secs(1))Full Example
Section titled “Full Example”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 })}