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 resultfor item in items { process_item(item).await?; // step name is always "process-item" — bug!}
// ✅ Correct — each iteration has a unique namefor (i, item) in items.iter().enumerate() { process_item(i, item.clone()).await?; // step names: "process-item-0", "process-item-1", ...}Two Ways to Make Step Names Unique
Section titled “Two Ways to Make Step Names Unique”Option 1 — {field} template in #[zart_step] (recommended)
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.}Option 2 — .named() at the call site
Section titled “Option 2 — .named() at the call site”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?;}Choosing between the two
Section titled “Choosing between the two”{field} template | .named() | |
|---|---|---|
| Where the name is set | Step definition | Call site in the durable body |
| Same step, different naming schemes | Requires two definitions | One definition, two call sites |
| Naming involves orchestration data | No — struct fields only | Yes — any expression |
| Risk of forgetting | Lower — always part of the definition | Higher — 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.
Pattern: Fetch + Iterate
Section titled “Pattern: Fetch + Iterate”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".
Pattern: Transforming Collections
Section titled “Pattern: Transforming Collections”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(())}Pattern: Chunked Processing
Section titled “Pattern: Chunked Processing”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)}Key Rules
Section titled “Key Rules”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.
See it in action
Section titled “See it in action”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.