Skip to content

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

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.

#[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>;
}

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:

MechanismWhere the name is setBest for
{field} template in #[zart_step]Step definitionLoop index or key fields that are always part of the identity
.named("...") at the call siteCall site in the durable bodyWhen the naming scheme depends on orchestration context

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()))
}
}

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
}
}

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?;

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)))
}
ConstructorBehavior
RetryConfig::none()No retries (default)
RetryConfig::fixed(n, delay)Constant delay between attempts
RetryConfig::exponential(n, initial)Doubles each time: initial, , , …
// 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))

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
}
ScopeBehavior
Global (default)Deadline = first_attempt + duration. All retries must fit within this window. The deadline is persisted in task metadata and survives restarts.
PerAttemptEach 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.

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.

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 restarts
let 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.

The #[zart_step] macro generates the exact same code as a manual implementation. Use whichever fits your style:

#[zart_step] macroManual impl ZartStep
VerbosityOne function + struct generatedStruct + trait impl
ControlGood for most casesFull control over struct, lifetimes
CallingDirect .await, or zart::step()zart::step(struct)
ReuseImport and call the functionImport and instantiate the struct
Lifetime handlingMacro injects 'a automaticallyYou 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?;

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,
})
}
}