Skip to content

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

Recurring Durable Executions

Many workflows need to run repeatedly — nightly reports, daily invoice generation, cache warmers, heartbeat checks, ETL pipelines. A naive approach uses a cron job that calls a script, but this breaks down when:

  • The script crashes midway through a multi-step process (invoice sent, but DB not updated)
  • You need visibility into past runs (did the report actually complete?)
  • Steps within the workflow need retries with checkpointing
  • Overlapping runs could cause duplicate side effects (double-charging customers, sending duplicate emails)

Recurring Durable Executions combine cron-like scheduling with Zart’s durable execution engine. Each tick starts a new DurableExecution that survives crashes, checkpoints each step, and respects overlap policies to prevent concurrent run chaos.

A recurring durable execution combines the scheduling power of a cron or fixed-delay ticker with the full reliability of Zart’s durable execution engine. Each tick starts a new DurableExecution handler — with step checkpointing, retries, admin visibility, and result persistence.

When to use recurring durable vs. plain recurring ScheduledTask

Section titled “When to use recurring durable vs. plain recurring ScheduledTask”
ConcernPlain ScheduledTaskRecurring Durable Execution
Step-level fault toleranceNoYes — each step is checkpointed
Automatic retriesManualYes — via RetryConfig
Admin / UI visibilityLimitedFull execution record
Parallel step supportNoYes
Appropriate forLightweight fire-and-forgetMulti-step workflows that must not be lost

Use a plain recurring ScheduledTask (see Recurring Scheduler Tasks) for lightweight jobs where a single function call is sufficient. Use recurring durable executions when you need steps, retries, and observability.

use zart::prelude::*;
use zart::OverlapPolicy;
use zart_scheduler::Recurrence;
use serde_json::json;
// 1. Implement your handler as usual
struct NightlyReport;
#[async_trait::async_trait]
impl DurableExecution for NightlyReport {
type Data = serde_json::Value;
type Output = serde_json::Value;
async fn run(&self, data: Self::Data) -> Result<Self::Output, TaskError> {
zart::require(FetchInventoryStep).await?;
zart::require(WriteReportStep).await?;
Ok(json!({ "status": "ok" }))
}
}
// 2. Register and schedule in one call
let worker = WorkerBuilder::from_backend(&pg)
.register_durable_task("nightly-report", NightlyReport)
.register_recurring_durable::<NightlyReport>(
"nightly-report", // task_id (unique per worker)
"report-{occurrence}", // execution ID template
// Fixed delay for local dev / testing:
Recurrence::FixedDelay { duration_ms: 5_000 },
// Cron for production:
// Recurrence::Cron { expression: "0 2 * * *".into(), timezone: "UTC".into() },
OverlapPolicy::SkipIfRunning, // overlap policy (see below)
json!({ "warehouse": "EU-1" }), // initial payload
)
.build();

When a new tick fires while a previous execution is still running, the OverlapPolicy controls what happens.

PolicyRunning executionNew occurrenceCanonical use case
SkipIfRunningContinues to runSilently skippedBatch reports, ETL pipelines
CancelAndRestartCancelled immediatelyFresh execution startsConfig refresh, cache warming
AlwaysStartContinues to runAlso startedIndependent audit windows, per-slot ingestion

The new tick is a no-op if any execution derived from the same template is currently in a Scheduled or Running state. Choose this when running the current instance to completion is more valuable than starting a fresh one.

The running execution is cancelled via DurableScheduler::cancel and a new one is started with the next occurrence ID. Choose this when you always want the latest state and partial work from the old run is undesirable.

Every tick unconditionally creates a new execution, even if one is already running. Choose this when occurrences are fully independent (e.g. time-windowed audit logs that must all complete regardless of overlap).

The id_template parameter supports one substitution token:

TokenReplaced with
{occurrence}The 0-based occurrence counter, incremented on every successful dispatch

Examples:

  • "report-{occurrence}""report-0", "report-1", "report-2", …
  • "audit-{occurrence}-eu""audit-0-eu", "audit-1-eu", …

The occurrence counter is stored in the recurring task’s metadata["occurrence"] field in zart_tasks. You can query it directly for introspection:

SELECT metadata->>'occurrence' AS occurrence
FROM zart_tasks
WHERE task_id = '__zart_recurring__:nightly-report';

Use DurableScheduler::wait to block until a specific occurrence finishes:

let record = durable
.wait("report-3", Duration::from_secs(30), None)
.await?;
println!("Status: {:?}", record.status);
println!("Result: {:?}", record.result);

To cancel a recurring task entirely, use DurableScheduler::stop_recurring:

let durable = DurableScheduler::from_backend(&pg);
durable.stop_recurring("nightly-report").await?;

This marks the recurring task as inactive. No new occurrences will be scheduled, but executions that are already running will complete normally.

For a complete, runnable example demonstrating all three overlap policies (SkipIfRunning, CancelAndRestart, AlwaysStart), see the recurring-durable example in the repository.

The example shows three scenarios:

ScenarioOverlap PolicyWhat You’ll See
Inventory SnapshotSkipIfRunningSecond tick skipped while first runs
Config RefreshCancelAndRestartStale run cancelled, fresh one starts
Audit WindowAlwaysStartMultiple runs overlap in parallel