ZartStep Trait
ZartStep is the core trait for defining individual workflow steps. Each step represents one unit of work whose result is persisted before the workflow moves on. If the process crashes mid-execution, completed steps are replayed from the database — never re-executed.
You can implement ZartStep manually for full control, or use the #[zart_step] macro which generates the struct and trait implementation for you.
The Trait
Section titled “The Trait”#[async_trait]pub trait ZartStep { /// The output type this step produces. type Output: Serialize + DeserializeOwned + Send + Sync;
/// Unique name for this step — used as the database key. /// /// Return `Cow::Borrowed("my-step")` for a static name. /// Return `Cow::Owned(format!("my-step-{}", self.n))` for a dynamic name. fn step_name(&self) -> Cow<'static, str>;
/// Optional retry configuration. Default: None (no retries). fn retry_config(&self) -> Option<RetryConfig> { None }
/// Optional wall-clock timeout (deadline). Default: None. fn timeout(&self) -> Option<Duration> { None }
/// Timeout scope: `Global` (default) or `PerAttempt`. Default: Global. fn timeout_scope(&self) -> TimeoutScope { TimeoutScope::Global }
/// Override the step identity at the call site. fn named(self, id: impl Into<String>) -> NamedStep<Self> where Self: Sized;
/// The step's actual logic. No ctx parameter — use zart::context() for metadata. async fn run(&self) -> Result<Self::Output, StepError>;}Step Name Uniqueness
Section titled “Step Name Uniqueness”Every step call within a durable execution must have a unique name. The name is concatenated with the execution ID to form the database task ID ({execution_id}:step:{step_name}), which has a PRIMARY KEY constraint. Reusing the same step name in a loop or calling a step twice with different parameters will silently return the cached result of the first call — producing wrong data.
Two mechanisms ensure uniqueness:
| Mechanism | Where the name is set | Best for |
|---|---|---|
{field} template in #[zart_step] | Step definition | Loop index or key fields that are always part of the identity |
.named("...") at the call site | Call site in the durable body | When the naming scheme depends on orchestration context |
Manual Implementation
Section titled “Manual Implementation”Define a struct that captures your step’s dependencies, then implement ZartStep:
use zart::prelude::*;use async_trait::async_trait;use std::borrow::Cow;use std::time::Duration;
struct LookupZipStep<'a> { client: &'a reqwest::Client, zip_code: &'a str,}
#[async_trait]impl ZartStep for LookupZipStep<'_> { type Output = (String, String);
fn step_name(&self) -> Cow<'static, str> { Cow::Borrowed("lookup-zip") }
fn retry_config(&self) -> Option<RetryConfig> { Some(RetryConfig::exponential(3, Duration::from_secs(1))) }
async fn run(&self) -> Result<Self::Output, StepError> { println!("[lookup-zip] Attempt {}", zart::context().current_attempt + 1);
let resp = self.client .get(format!("https://api.zippopotam.us/us/{}", self.zip_code)) .send().await .map_err(|e| StepError::Failed { step: "lookup-zip".into(), reason: e.to_string() })?;
let data: ZipResponse = resp.json().await .map_err(|e| StepError::Failed { step: "lookup-zip".into(), reason: e.to_string() })?;
let place = data.places.first() .ok_or_else(|| StepError::Failed { step: "lookup-zip".into(), reason: "no place found".into() })?;
Ok((place.place_name.clone(), place.state.clone())) }}Dynamic Names in Manual Implementations
Section titled “Dynamic Names in Manual Implementations”When the step name must include runtime data, return Cow::Owned:
struct ProcessRegionStep { region: String,}
#[async_trait]impl ZartStep for ProcessRegionStep { type Output = RegionResult;
fn step_name(&self) -> Cow<'static, str> { Cow::Owned(format!("process-{}", self.region)) }
async fn run(&self) -> Result<Self::Output, StepError> { process_region(&self.region).await }}Using the Step
Section titled “Using the Step”Execute via zart::step():
let (city, state) = zart::step(LookupZipStep { client: &client, zip_code: &data.zip_code,}).await?;Or if the step implements IntoFuture (macro-generated steps do), await it directly:
let (city, state) = lookup_zip(&client, &data.zip_code).await?;Retry Configuration
Section titled “Retry Configuration”Override retry_config() to enable automatic retries with a specific policy:
fn retry_config(&self) -> Option<RetryConfig> { Some(RetryConfig::exponential(5, Duration::from_secs(1)))}| Constructor | Behavior |
|---|---|
RetryConfig::none() | No retries (default) |
RetryConfig::fixed(n, delay) | Constant delay between attempts |
RetryConfig::exponential(n, initial) | Doubles each time: initial, 2×, 4×, … |
// 3 retries, 5 seconds apartRetryConfig::fixed(3, Duration::from_secs(5))
// 5 retries: 1s, 2s, 4s, 8s, 16sRetryConfig::exponential(5, Duration::from_secs(1))Timeout Configuration
Section titled “Timeout Configuration”Override timeout() to enforce a wall-clock deadline:
fn timeout(&self) -> Option<Duration> { Some(Duration::from_secs(300)) // 5-minute deadline}By default, the timeout uses global scope — the deadline is calculated from the first attempt, and all retries share the same window. Override timeout_scope() to change this:
fn timeout(&self) -> Option<Duration> { Some(Duration::from_secs(30))}
fn timeout_scope(&self) -> TimeoutScope { TimeoutScope::PerAttempt // each retry gets a fresh 30-second countdown}| Scope | Behavior |
|---|---|
Global (default) | Deadline = first_attempt + duration. All retries must fit within this window. The deadline is persisted in task metadata and survives restarts. |
PerAttempt | Each retry attempt gets a fresh countdown. No deadline is persisted. |
Timeout is always terminal — it never triggers a retry. Retry is reserved for business errors only.
See Timeouts and Deadlines for a full conceptual walkthrough.
Loops and Repeated Calls
Section titled “Loops and Repeated Calls”Each step call in an execution must have a unique name — reusing the same name in a loop silently returns the first iteration’s cached result for all subsequent iterations.
Two mechanisms make this easy:
{field}template — embed a struct field in the step name at definition time:#[zart_step("fetch-page-{page}")]. The macro generates a dynamic name at runtime..named()— override the step identity at the call site:my_step().named(format!("my-step-{i}")).await?.
See Durable Loops for full patterns, examples, and guidance on which mechanism to use.
Parallel Steps
Section titled “Parallel Steps”Schedule multiple steps for concurrent execution using zart::schedule():
let h1 = zart::schedule(check_service("auth-api".into()));let h2 = zart::schedule(check_service("payments".into()));let h3 = zart::schedule(check_service("users-db".into()));
// Wait for all — survives restartslet results = zart::wait(vec![h1, h2, h3]).await?;Each zart::schedule call creates a separate row in the scheduler. Sub-steps can run on different workers simultaneously.
Macro vs Manual
Section titled “Macro vs Manual”The #[zart_step] macro generates the exact same code as a manual implementation. Use whichever fits your style:
#[zart_step] macro | Manual impl ZartStep | |
|---|---|---|
| Verbosity | One function + struct generated | Struct + trait impl |
| Control | Good for most cases | Full control over struct, lifetimes |
| Calling | Direct .await, or zart::step() | zart::step(struct) |
| Reuse | Import and call the function | Import and instantiate the struct |
| Lifetime handling | Macro injects 'a automatically | You manage lifetimes explicitly |
// Macro style#[zart_step("lookup-zip", retry = "exponential(3, 1s)")]async fn lookup_zip(client: &reqwest::Client, zip_code: &str) -> Result<(String, String), StepError> { // ... step logic}
// Direct await:let (city, state) = lookup_zip(&client, &data.zip_code).await?;Full Example
Section titled “Full Example”A complete durable handler using manual ZartStep structs:
use zart::prelude::*;use async_trait::async_trait;use std::borrow::Cow;use std::time::Duration;
// ── Step 1: Validate the request ──────────────────────────────────────────────
struct ValidateRequestStep { request: ApprovalRequest,}
#[async_trait]impl ZartStep for ValidateRequestStep { type Output = String;
fn step_name(&self) -> Cow<'static, str> { Cow::Borrowed("validate-request") }
async fn run(&self) -> Result<Self::Output, StepError> { if self.request.requester_name.is_empty() { return Err(StepError::Failed { step: "validate-request".into(), reason: "empty name".into(), }); } Ok(format!("Validated request from {}", self.request.requester_name)) }}
// ── Step 2: Process after approval (no retry — idempotent) ────────────────────
struct ProcessApprovedStep { resource: String, requester: String,}
#[async_trait]impl ZartStep for ProcessApprovedStep { type Output = String;
fn step_name(&self) -> Cow<'static, str> { Cow::Borrowed("process-approved") }
async fn run(&self) -> Result<Self::Output, StepError> { Ok(format!("Provisioned {} for {}", self.resource, self.requester)) }}
// ── Durable handler ───────────────────────────────────────────────────────────
struct ApprovalTask;
#[async_trait]impl DurableExecution for ApprovalTask { type Data = ApprovalRequest; type Output = ApprovalOutput;
async fn run(&self, data: Self::Data) -> Result<Self::Output, TaskError> { zart::step(ValidateRequestStep { request: data.clone() }).await?;
let decision: ApprovalDecision = zart::wait_for_event("manager-approval", Some(Duration::from_secs(7200))).await?;
if decision.approved { zart::step(ProcessApprovedStep { resource: data.resource.clone(), requester: data.requester_name.clone(), }).await?; }
Ok(ApprovalOutput { decision: if decision.approved { "approved" } else { "rejected" }.into(), requester: data.requester_name, resource: data.resource, reviewer: decision.reviewer, comment: decision.comment, }) }}