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.
Workflow JSON
Section titled “Workflow JSON”Copy this and paste it into a new workflow with Cmd+V:
{ "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.
How it works
Section titled “How it works”Two steps:
- try-acquire —
data-store.atomic-upsertagainst theleasestable with the lease key.insertValuerecords this run’s execution ID as theholder.set: []means “do nothing if the row already exists” — so a second caller finding the row already there leaves it untouched and getsinserted: false. - check-lease — if
insertedistrue, this run is the leader: do the work, then release-lease deletes the row. Ifinsertedisfalse, 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.
Backing off when the lease is held
Section titled “Backing off when the lease is held”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:
- The webhook handler writes an entry to a
pending_opstable (onedata-store.setoratomic-upsert) and returns immediately. - 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.
What to customize first
Section titled “What to customize first”- Replace the work step. The
core.logstep underthenis 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
deletestep 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
elsebranch 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.
Related recipes
Section titled “Related recipes”- Async Queue Dispatcher — the most common consumer of a lease (only one dispatcher should drain the queue)
- Idempotency - Process Event Once — same primitive (
atomic-upsertwithset: []), different framing: dedupe by ID vs. dedupe by holder