Skip to content

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

Durable Loops

Regular Rust loops are not durable — if your process restarts mid-loop, all progress is lost and the loop starts over. Zart solves this by persisting each iteration’s result independently, keyed by a unique step name.

Why Step Names Must Be Unique Per Iteration

Section titled “Why Step Names Must Be Unique Per Iteration”

Each step call is stored in the database as {execution_id}:step:{step_name} with a PRIMARY KEY constraint. If two iterations share the same step name, the second iteration finds the first’s cached result in the database and silently returns it — producing wrong data with no error.

// ❌ Wrong — all iterations share "process-item", second gets first's result
for item in items {
process_item(item).await?;
// step name is always "process-item" — bug!
}
// ✅ Correct — each iteration has a unique name
for (i, item) in items.iter().enumerate() {
process_item(i, item.clone()).await?;
// step names: "process-item-0", "process-item-1", ...
}
Section titled “Option 1 — {field} template in #[zart_step] (recommended)”

Embed a struct field directly in the step name at definition time. The macro generates a dynamic name at runtime using format!:

#[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
}
// In the durable handler — nothing special needed at the call site:
for (i, item) in items.iter().enumerate() {
process_item(i, item.clone()).await?;
}

Any field implementing Display can appear in the template. Multiple placeholders are supported:

#[zart_step("process-{region}-{shard}")]
async fn process_shard(region: &str, shard: u32) -> Result<(), StepError> {
// "process-us-east-1-0", "process-us-east-1-1", etc.
}

Override the step name when the unique key isn’t captured in the step struct, or when you reuse a step definition under different identities:

#[zart_step("send-notification")]
async fn send_notification(user_id: &str) -> Result<(), StepError> {
notify(user_id).await
}
// Give each call a unique name at the call site:
for (i, user_id) in recipients.iter().enumerate() {
send_notification(user_id)
.named(format!("send-notification-{i}"))
.await?;
}
{field} template.named()
Where the name is setStep definitionCall site in the durable body
Same step, different naming schemesRequires two definitionsOne definition, two call sites
Naming involves orchestration dataNo — struct fields onlyYes — any expression
Risk of forgettingLower — always part of the definitionHigher — must remember at each call

Use {field} when the unique key is naturally a field of the step (loop index, item ID). Use .named() when the unique key comes from orchestration context not captured in the struct.

Always fetch the item list inside a step. This ensures the exact same list is replayed after a crash — even if the underlying data changes between attempts.

#[zart_step("fetch-items")]
async fn fetch_items() -> Result<Vec<Item>, StepError> {
db.fetch_pending_items().await
}
#[zart_step("process-item-{index}")]
async fn process_item(index: usize, item: Item) -> Result<Output, StepError> {
processor.handle(&item).await
}
#[zart_durable("batch-processor")]
async fn process_batch(data: BatchData) -> Result<BatchResult, TaskError> {
let items = fetch_items().await?;
let count = items.len();
for (i, item) in items.into_iter().enumerate() {
process_item(i, item).await?;
}
Ok(BatchResult { processed: count })
}

If the process restarts after processing items 0–3, Zart skips "process-item-0" through "process-item-3" (returning stored results instantly) and resumes from "process-item-4".

Read items, transform each one, write results — all durable:

#[zart_step("fetch-records")]
async fn fetch_records() -> Result<Vec<Record>, StepError> {
db.query("SELECT * FROM pending").await
}
#[zart_step("transform-record-{index}")]
async fn transform_record(index: usize, record: Record) -> Result<Output, StepError> {
transformer.apply(&record).await
}
#[zart_step("write-results")]
async fn write_results(outputs: Vec<Output>) -> Result<(), StepError> {
db.batch_insert(&outputs).await
}
#[zart_durable("data-pipeline")]
async fn run_pipeline(_data: PipelineInput) -> Result<(), TaskError> {
let records = fetch_records().await?;
let mut outputs = Vec::new();
for (i, record) in records.into_iter().enumerate() {
let output = transform_record(i, record).await?;
outputs.push(output);
}
write_results(outputs).await?;
Ok(())
}

For large datasets, process in fixed-size chunks. Each chunk is its own durable step:

const CHUNK_SIZE: usize = 100;
#[zart_step("get-total-count")]
async fn get_total_count() -> Result<usize, StepError> {
db.count_pending_items().await
}
#[zart_step("process-chunk-{chunk_idx}")]
async fn process_chunk(chunk_idx: usize) -> Result<usize, StepError> {
let offset = chunk_idx * CHUNK_SIZE;
let items = db.fetch_items(offset, CHUNK_SIZE).await?;
processor.process_batch(&items).await?;
Ok(items.len())
}
#[zart_durable("chunked-processor")]
async fn run_chunked(data: BatchInput) -> Result<usize, TaskError> {
let total = get_total_count().await?;
let num_chunks = total.div_ceil(CHUNK_SIZE);
let mut processed = 0;
for chunk_idx in 0..num_chunks {
processed += process_chunk(chunk_idx).await?;
}
Ok(processed)
}

Every iteration needs a unique step name — use a loop index, item ID, or any value that distinguishes the call. Without it, all iterations silently share the same cached result.

Fetch inside a step — wrap data-fetching in its own step so the same dataset is used on replay.

Plain for loop — no special macro or wrapper needed. A standard Rust for with direct step .await is all it takes.

The Durable Loops example processes a batch of reports using both mechanisms: the {index} template for per-item processing steps and .named() for conditional notification steps.