Skip to content

Leader Election Lease

A workflow that runs at most one copy of itself at a time. The first caller atomically inserts a holder row; subsequent callers see it already present and back off. Useful when a workflow needs to be a singleton — a dispatcher that drains a queue, a daily aggregation job that mustn’t double-run, a maintenance task on shared state.

Copy this and paste it into a new workflow with Cmd+V:

leader-election-lease.json
{
"name": "leader-election-lease",
"description": "Acquire a single-holder lease. Exactly one concurrent caller sees inserted=true and proceeds as the holder; the others see inserted=false and back off. The holder is identified by the workflow execution ID so traces show which run holds the lease.",
"initial": {
"leaseKey": "demo-dispatcher-lease"
},
"steps": [
{
"stepId": "try-acquire",
"stepType": "data-store.atomic-upsert",
"input": {
"tableName": "leases",
"key": "{{ initial.leaseKey }}",
"insertValue": {
"holder": "{{ $execution.id }}",
"acquiredAt": "{{ $util.now }}"
},
"set": []
}
},
{
"stepId": "check-lease",
"stepType": "core.if",
"input": {
"condition": { "==": [{ "var": "try-acquire.inserted" }, true] },
"then": [
{
"stepId": "do-leased-work",
"stepType": "core.log",
"input": {
"message": "Lease acquired by run {{ $execution.id }}. Replace this step with the singleton work."
}
},
{
"stepId": "release-lease",
"stepType": "data-store.delete",
"input": {
"tableName": "leases",
"key": "{{ initial.leaseKey }}"
}
}
],
"else": [
{
"stepId": "lease-held-elsewhere",
"stepType": "core.log",
"input": {
"message": "Lease already held by {{ try-acquire.value.holder }} (acquired {{ try-acquire.value.acquiredAt }}). Backing off."
}
}
]
}
}
]
}

Connections needed: none.

Trigger: anything that might fire multiple concurrent copies — a schedule, a webhook with bursty traffic, manual runs.

Table: a leases table will be created on first run. One row per leaseKey; the value records the current holder’s execution ID and the time the lease was acquired.

Two steps:

  1. try-acquiredata-store.atomic-upsert against the leases table with the lease key. insertValue records this run’s execution ID as the holder. set: [] means “do nothing if the row already exists” — so a second caller finding the row already there leaves it untouched and gets inserted: false.
  2. check-lease — if inserted is true, this run is the leader: do the work, then release-lease deletes the row. If inserted is false, log the holder and stop.

Because the insert is one atomic operation, two runs that fire at the same time will see exactly one inserted: true. The losers see the winner’s holder ID and back off.

The recipe stops cleanly when try-acquire.inserted is false — but what happens next depends on what triggered the workflow. There’s no first-class “schedule this workflow to run again in N seconds” primitive, so the right answer is different for different triggers.

Scheduled triggers — let the schedule retry (recipe default)

Section titled “Scheduled triggers — let the schedule retry (recipe default)”

If the workflow is fired by a Schedule trigger, the simplest answer is to do nothing. This run exits; the schedule’s next fire tries again. The cadence of the schedule sets how soon a backed-off run gets another shot — every minute, every 5 minutes, whatever you set it to.

This is what the recipe ships as. No retry plumbing needed; the schedule is the retry.

Bounded in-run retry — wait and try again

Section titled “Bounded in-run retry — wait and try again”

When you genuinely need the same run to retry — usually because the original trigger is synchronous and the caller is waiting on the result — add a core.wait (say, 5–30 seconds) followed by a core.goto back to try-acquire. Keep an attempt counter so a stuck lease doesn’t pin the run indefinitely.

This works, but the trade-off is real: the run holds a worker for the entire wait. Reasonable when leases are expected to release in seconds; not when they can be held for minutes.

Webhooks and other async triggers — decompose instead of retry

Section titled “Webhooks and other async triggers — decompose instead of retry”

If a webhook fires the workflow and the lease is held, the cleanest move is to not acquire the lease in the webhook handler at all. Decompose:

  1. The webhook handler writes an entry to a pending_ops table (one data-store.set or atomic-upsert) and returns immediately.
  2. A separate scheduled dispatcher acquires the lease (using this recipe) and drains the table.

The webhook stays fast and never blocks on the lease; the dispatcher handles the singleton constraint on its own schedule. See Async Queue Dispatcher for the dispatcher side. This composition — producer-without-lease plus dispatcher-with-lease — is what production systems tend to settle on for lease-gated work fed by external events.

  • Replace the work step. The core.log step under then is a placeholder. Put the singleton work there — the queue dispatcher, the daily aggregation, whatever should only run once at a time.
  • Add a TTL / lease expiry. This recipe holds the lease until the workflow finishes (and the delete step releases it). If the workflow crashes between acquire and release, the lease sits orphaned. Two options: (1) add a TTL on the lease row so it expires automatically; (2) add a separate scheduled workflow that deletes leases older than some threshold.
  • Pick a backoff strategy for the else branch that matches your trigger. See Backing off when the lease is held.
  • Use different lease keys for different singletons. One workflow’s lease key shouldn’t block another’s. Name them per-purpose: daily-aggregation, ucce-dispatcher, nightly-cleanup.