Skip to content

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

Macros

The proc-macros transform ordinary Rust async functions into full DurableExecution implementations and ZartStep step definitions — no trait boilerplate required. They are bundled with zart and re-exported directly, so no separate dependency is needed.

[dependencies]
zart = "0.1" # macros are included — use zart::zart_durable, zart::zart_step, etc.

Annotate an async function to make it a durable workflow. The macro generates the DurableExecution impl and a unit struct named after the function in PascalCase.

use zart_macros::zart_durable;
#[zart_durable("checkout", timeout = "10m")]
async fn checkout(order: Order) -> Result<Receipt, zart::error::TaskError> {
// workflow body — use zart::* free functions for all operations
Ok(Receipt { /* ... */ })
}
// Macro generates `struct Checkout;` and `impl DurableExecution for Checkout`
// Register with:
// .register_durable_task("checkout", Checkout)
// in the WorkerBuilder chain

Parameters:

ParameterTypeRequiredDescription
"name"stringyesTask name used when scheduling
timeoutduration stringnoWall-clock timeout, e.g. "30m", "2h", "90s"

The function must accept the workflow payload as its only parameter and return Result<T, TaskError> where T implements Serialize.

Turn an async function into a durable step. The macro generates a step struct, implements ZartStep, and wires up IntoFuture so the step can be directly awaited.

use zart_macros::zart_step;
#[zart_step("charge-card", retry = "exponential(3, 2s)")]
async fn charge_card(card: &str, amount: i64) -> Result<ChargeResult, StepError> {
if zart::context().is_retry() {
println!("Retry attempt #{}", zart::context().current_attempt);
}
stripe.charge(card, amount).await
}

Parameters:

ParameterTypeRequiredDescription
"name"stringyesStep name for database tracking
retrystringnoRetry config: "fixed(n, dur)" or "exponential(n, dur)"
timeoutduration stringnoPer-step timeout, e.g. "5m", "30s"

For the charge_card example above, the macro produces:

  1. ChargeCardStep<'_> struct — holds the parameters (card, amount)
  2. impl ZartStep — with step_name(), retry_config(), and run() (no ctx parameter)
  3. Builder functionfn charge_card(card, amount) -> ChargeCardStep<'_> (replaces the original fn)
  4. impl IntoFuture — delegates to zart::step(self), enabling direct .await

Direct await (recommended):

let charge = charge_card(&card, amount).await?;

The step builder returns a value that implements IntoFuture. Awaiting it calls zart::step() internally, which persists the result.

Via zart::step() explicitly — useful when passing the step to another function:

let charge = zart::step(charge_card(&card, amount)).await?;

Via zart::schedule() for parallel execution:

let handle = zart::schedule(charge_card(&card, amount));
// ... schedule other steps ...
let results = zart::wait(vec![handle]).await?;

Use a {field} placeholder in the step name for loops or repeated steps:

#[zart_step("process-item-{index}")]
async fn process_item(index: usize, item: Item) -> Result<Output, StepError> {
// step_name() returns "process-item-0", "process-item-1", etc.
transform(item).await
}

For call-site naming, use .named():

notify_user(user_id).named(format!("notify-{i}")).await?;

See Durable Loops for detailed patterns.

The two styles are fully equivalent. Choose the one that fits your team’s preferences.

With macros (recommended for most workflows):

use zart::prelude::*;
use zart_macros::{zart_durable, zart_step};
#[zart_step("send-email")]
async fn send_email(email: &str) -> Result<(), StepError> {
send_welcome_email(email).await
}
#[zart_step("create-stripe-customer", retry = "exponential(3, 2s)")]
async fn create_customer(email: &str) -> Result<String, StepError> {
create_stripe_customer(email).await
}
#[zart_durable("onboarding", timeout = "50h")]
async fn onboarding(data: OnboardingData) -> Result<OnboardingResult, TaskError> {
send_email(&data.email).await?;
let customer_id = create_customer(&data.email).await?;
let _: VerifyPayload = zart::wait_for_event(
"email-verified",
Some(Duration::from_secs(172_800)),
).await?;
Ok(OnboardingResult { customer_id })
}

Manually implementing the traits:

use zart::prelude::*;
use async_trait::async_trait;
use std::time::Duration;
struct SendEmailStep { email: String }
#[async_trait]
impl ZartStep for SendEmailStep {
type Output = ();
fn step_name(&self) -> Cow<'static, str> { Cow::Borrowed("send-email") }
async fn run(&self) -> Result<Self::Output, StepError> {
send_welcome_email(&self.email).await
}
}
struct CreateCustomerStep { email: String }
#[async_trait]
impl ZartStep for CreateCustomerStep {
type Output = String;
fn step_name(&self) -> Cow<'static, str> { Cow::Borrowed("create-stripe-customer") }
fn retry_config(&self) -> Option<RetryConfig> {
Some(RetryConfig::exponential(3, Duration::from_secs(2)))
}
async fn run(&self) -> Result<Self::Output, StepError> {
create_stripe_customer(&self.email).await
}
}
pub struct OnboardingTask;
#[async_trait]
impl DurableExecution for OnboardingTask {
type Data = OnboardingData;
type Output = OnboardingResult;
async fn run(&self, data: OnboardingData) -> Result<Self::Output, TaskError> {
zart::step(SendEmailStep { email: data.email.clone() }).await?;
let customer_id: String =
zart::step(CreateCustomerStep { email: data.email.clone() }).await?;
let _: VerifyPayload = zart::wait_for_event(
"email-verified",
Some(Duration::from_secs(172_800)),
).await?;
Ok(OnboardingResult { customer_id })
}
fn max_retries(&self) -> usize { 3 }
fn timeout(&self) -> Option<Duration> { Some(Duration::from_secs(180_000)) }
}

Both #[zart_durable] and #[zart_step] accept human-readable duration strings:

StringDuration
"30s"30 seconds
"5m"5 minutes
"2h"2 hours
"1d"1 day