Playbook: n8n per-resource Redis singleton lock (INCR + EXPIRE + DEL)
Reusable pattern for any n8n scheduleTrigger workflow whose real-world run time can exceed
the trigger interval, and whose runs would corrupt shared state (queues, folders, mailboxes,
inboxes, dedup tables) if two runs of the SAME resource execute concurrently.
Origin: issue #4008 — the W4 Direct API
Ingestion workflow (bms-4, id=MUjApqruo6H88ebw) has three scheduleTriggers (min :20 / :30 /
:40) on separate folders (rechnungen / gutshrifts / standard). On 2026-07-12 the rechnungen
backlog took ~70 min, so the next :20 fired while the first was still running, both walked the
same folder, and 97.7% of the resulting 427 rows landed on processStatus 300
(“duplicates to confirm”). Backend dedup prevented double-booking, but the ops cost of clearing
300s was large. This pattern prevents the overlap at the source.
When to use this pattern
Apply if all of the following are true. Skip if any is false.
| Question | Answer to apply |
|---|---|
Is the trigger a scheduleTrigger (or webhook that can fire back-to-back on the same resource)? | yes |
| Can one run legitimately take longer than the interval between two consecutive fires? | yes |
| Do two overlapping runs on the SAME resource corrupt state (duplicates, wrong ordering, over-billing)? | yes |
| Is there already a Redis instance the workflow can reach? | yes |
| Would the ops cost of a mid-run crash be smaller than the ops cost of an overlap? | yes |
If NO to any → use a plain scheduleTrigger with a longer interval, or a queue-mode SET NX EX
inside a Code node, or delegate to a message broker. This pattern is specifically for the
“same-folder overlap on schedule” shape.
Concrete cases in this stack that qualify:
- W4 Direct API Ingestion — folder-scoped runs (this issue).
- Any bulk-mail sender that reads a “to-send” queue table on a fixed schedule.
- Any cron-driven export that touches a per-tenant working directory.
Design in one paragraph
Each run acquires a lock keyed on the resource it will touch (w4ingest:lock:rechnungen
etc.) with INCR. INCR is atomic in Redis — exactly one caller sees 1. If a run sees 1
it owns the lock, sets a TTL (EXPIRE key 600), and proceeds. If a run sees N ≥ 2 it means
another run already owns the lock; the loser exits immediately (NoOp) and does NOT touch
the counter or the TTL (otherwise it would release the winner’s lock early). The winner
heartbeats EXPIRE key 600 on every unit of work (per file / per record) so a long run
does not expire mid-flight, and calls DEL key on the “done” branch. If the run crashes,
the TTL is the only cleanup — the next scheduled fire finds INCR returning 1 again once
the TTL elapses, and takes over cleanly.
Why the key is per-resource, not global: three folders that do NOT share state should still run in parallel. Only the same folder blocks itself.
Why INCR and not SET NX EX: the n8n Redis node exposes INCR / EXPIRE / DEL as first-class
operations but does NOT expose a compound SET NX EX — that would require a Code node using
ioredis, which the n8n Code sandbox blocks. INCR gives the same atomic
“exactly one caller sees 1” guarantee without any Code node.
Node schema (three Redis nodes + one IF, per resource branch)
[scheduleTrigger] ─► [Redis INCR w4ingest:lock:<folder>] ─► [IF: value == 1?]
│
┌───────────────────────────────────────┴──────────────┐
│ TRUE (this run OWNS the lock) │ FALSE (loser)
▼ ▼
[Redis EXPIRE key 600] [NoOp — end branch]
▼ (do NOT touch counter/TTL)
[existing search / loop nodes]
│ (inside loop, per file)
▼
[Redis EXPIRE key 600] ◄── heartbeat
│
▼
[existing rest of loop body]
│ (loop "done" branch)
▼
[Redis DEL key] ─► [end]
Node count added per resource branch: 3 Redis operations + 1 IF + 1 NoOp = 5 nodes, plus 1 in-loop heartbeat Redis node.
Node parameters
All Redis nodes use n8n-nodes-base.redis and the bms-4 Redis credential already in the n8n
vault (queue-mode Redis; do not create a new one).
1. Acquire — INCR
{
"operation": "incr",
"key": "w4ingest:lock:rechnungen"
}Response shape: { "value": 1 } on first run, { "value": 2 } (or higher) on losers.
2. Gate — IF
{
"conditions": {
"number": [
{
"value1": "={{ $json.value }}",
"operation": "equal",
"value2": 1
}
]
}
}- TRUE branch → continue to
EXPIRE→ search → loop. - FALSE branch →
NoOp→ end. Do NOTDECR,DEL, or touch TTL in the loser branch. Any of those would release the winner’s lock while the winner is still running.
3. Set TTL — EXPIRE (winner only, once, right after IF-TRUE)
{
"operation": "expire",
"key": "w4ingest:lock:rechnungen",
"ttl": 600
}TTL of 600 s (10 min) is the “crash-recovery window” — how long a stalled workflow blocks the next attempt. If the workflow runs on a 60-min interval and a normal run is 5–70 min, 600 s is short enough that a crash mid-run does not stall the next scheduled fire beyond one interval, and long enough that a healthy run does not expire between heartbeats.
4. Heartbeat — EXPIRE (inside loop body, per file/record)
Same node parameters as (3). Placed after (or before) the per-file work node so every processed unit resets the TTL to 600 s. In practice the winner’s TTL is refreshed every ~15 s (one file), so a run of 70 min stays locked for its entire duration; only a genuine crash lets the TTL count down.
5. Release — DEL (winner “done” branch only)
{
"operation": "delete",
"key": "w4ingest:lock:rechnungen"
}Placed on the loop’s “done” output — the branch that fires after the last file. Not on the loop iteration output.
Naming convention for the lock key
<workflow-slug>:lock:<resource-id>
<workflow-slug>— short, stable identifier for the workflow (not the workflow ID, which changes if the workflow is duplicated). Examples:w4ingest,w3digest,mail-cron.<resource-id>— the smallest thing that must not overlap with itself. Folder name, tenant id, mailbox slug. NOT the trigger ID and NOT the execution ID.
Three folders in W4 ingest → three keys:
w4ingest:lock:rechnungenw4ingest:lock:gutshriftsw4ingest:lock:standard
Rechnungen can run for 70 min while gutshrifts runs its 20 min — they do not block each other. Only rechnungen blocks rechnungen.
Rationale — INCR vs SET NX EX
| Feature | SET NX EX (would-be ideal) | INCR + EXPIRE (this pattern) |
|---|---|---|
| Atomic “first caller wins” | yes | yes (INCR is atomic) |
| Sets TTL in the same op | yes | no — second call needed |
| TTL race window (crash between INCR and EXPIRE) | none | small: if the workflow crashes AFTER INCR but BEFORE EXPIRE, the key has no TTL and never expires |
Requires n8n Code node with ioredis | yes — n8n Redis node has no compound SET NX EX | no — pure n8n Redis node ops |
| Works in n8n Code sandbox | no (require('ioredis') blocked) | yes |
The INCR-then-EXPIRE race is the only real downside. Mitigations:
- The two nodes execute back-to-back in-process; a crash between them is possible but rare.
- A watchdog n8n workflow (out of scope for this pattern) can
SCANfor keys matching<workflow-slug>:lock:*withTTL == -1and set a TTL — belt-and-braces if this becomes a real problem. - Alternatively, wrap the two ops in a Redis MULTI/EXEC transaction using two n8n Redis nodes chained — but the n8n Redis node has no MULTI/EXEC UI, so this again requires a Code node.
For the workflows this pattern targets, the crash-between-INCR-and-EXPIRE window is milliseconds against a 10-minute TTL — the trade is favorable.
Crash-TTL handling — the failure modes
| Scenario | What happens | Recovery |
|---|---|---|
| Winner runs normally, DEL fires on done branch | Lock released immediately. | Next trigger sees INCR → 1 and runs. |
Winner crashes AFTER EXPIRE set but BEFORE done | Lock still held. Heartbeats stop. TTL counts down 600 s. | Next trigger within 10 min sees INCR → 2+ and NoOps; a trigger after 10 min sees INCR → 1 and runs. Backlog delayed by ≤10 min. |
Winner crashes BETWEEN INCR and EXPIRE | Key exists with no TTL. Never expires. | Detected via watchdog OR by a human noticing “every scheduled run is a NoOp” — redis-cli DEL <key> unblocks. |
Loser accidentally DELs | Winner’s lock released mid-run; the next scheduled fire enters as a second winner → OVERLAP (the exact bug this pattern prevents). | Prevention: loser branch has ONLY NoOp. Any Redis op in the loser branch is a design bug — flag in review. |
Loser accidentally EXPIREs / DECRs | Same as above — releases winner’s lock or extends it wrongly. | Same prevention: NO Redis ops in loser branch. |
| Redis restarts mid-run | Key gone. Next INCR returns 1. If the winner is still running, it will still EXPIRE and DEL, but the next scheduled fire is a new winner too → OVERLAP. | Redis restarts are rare; if this is a concern, use Redis persistence (appendonly yes) or accept the once-per-redis-restart overlap. |
The dominant failure mode is #2 (winner crashes) — that’s the case the TTL is designed for. Everything else is a design-review concern: keep the loser branch pure NoOp, add a heartbeat in the loop, put DEL only on the done branch.
Applying this to the W4 Direct API Ingestion workflow (worked example)
Workflow: MUjApqruo6H88ebw on bms-4.
Current structure per branch:
[scheduleTrigger :20] ─► [search rechnungen folder] ─► [loop over files] ─► [end]
Modified structure per branch:
[scheduleTrigger :20]
▼
[Redis INCR w4ingest:lock:rechnungen]
▼
[IF value == 1]
├── TRUE ─► [Redis EXPIRE …:rechnungen 600]
│ ▼
│ [search rechnungen folder]
│ ▼
│ [loop over files]
│ │
│ ├── iteration ─► [Redis EXPIRE …:rechnungen 600]
│ │ ▼
│ │ [existing per-file work]
│ │ ▼ (back to loop)
│ │
│ └── done ─► [Redis DEL …:rechnungen] ─► [end]
│
└── FALSE ─► [NoOp] ─► [end]
Repeat identically for gutshrifts (:30) and standard (:40) — same shape, only the key suffix and folder name differ.
Rollout steps (for the follow-up implementation issue, NOT this PR)
- Confirm the workflow is
active:falsebefore editing (it currently is — safe edit window). - Snapshot current JSON:
GET /rest/workflows/MUjApqruo6H88ebw→ commit ton8n-workflows/w4-direct-api-ingestion.pre-4008.jsonso a rollback is one file away. - Add the 5 lock nodes + 1 heartbeat per branch (× 3 branches) via the n8n REST API.
- Export the modified JSON to
n8n-workflows/w4-direct-api-ingestion.json— this is the first time this workflow lives in git. - Test with a manual webhook trigger on the rechnungen branch:
- First POST → confirm winner:
INCR → 1, EXPIRE set, search runs. - Second POST WHILE first is still running → confirm loser:
INCR → 2, NoOp, execution finishes in <2 s. redis-cli TTL w4ingest:lock:rechnungenshould show a decreasing value that jumps back to ~600 s at every heartbeat.
- First POST → confirm winner:
- Kill the winner mid-run (
docker restart n8n-worker) → confirmTTLcounts down to zero within 10 min and the next scheduled fire wins. - Re-enable the workflow only after all three tests pass. Choose a window between :00–:19 or :41–:59 to avoid stepping on the real triggers.
- If any test is red → leave the workflow
active:falseand file a bug back to this playbook (the pattern is at fault, not the workflow).
Checklist — reviewer / self-review
Before merging a workflow that uses this pattern:
- Every
scheduleTriggerbranch has anINCR→IFgate immediately after it. - Lock key is
<workflow-slug>:lock:<resource-id>— stable across workflow renames. - Winner branch:
EXPIRE key 600executes exactly once, right after the IF-TRUE output. - Winner branch:
EXPIRE key 600heartbeat inside the loop, per unit of work. - Winner branch:
DEL keyon the loop’s DONE output only (not the iteration output). - Loser branch: contains a
NoOpand nothing else — no Redis operations at all. - Three separate keys if three resources — never a shared key across parallel resources.
- TTL matches the expected max healthy run duration × 1.2 (buffer). 600 s for W4 ingest; adjust up for slower workflows, never down below ~2× heartbeat interval.
- The Redis credential is the existing bms-4 queue-mode Redis credential — do not create a new one and do not put the connection string in a node parameter.
Alerting on stale locks (optional but recommended)
A separate n8n workflow can SCAN 0 MATCH <workflow-slug>:lock:* every 5 min and alert on:
- Any key with
TTL == -1(INCR-EXPIRE race) → likely stale. - Any key with
TTL < 30AND the owning workflow has an execution inrunningstate older than the TTL was set for → likely a heartbeat gap.
Route to the existing Discord webhook P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL per the
p24-infra error-notification standard.
This is not required for the pattern to be correct — it is defensive monitoring for the two crash modes the TTL alone does not fix (Redis restart while winner running; crash between INCR and EXPIRE).
See also
docs/pinbox24/pinbox24-workflow-engine.md— inventory of W3/W4 ingest workflows.docs/playbooks/n8n/n8n-workflow-creation.md— n8n workflow authoring conventions.docs/playbooks/n8n/n8n-bms4-stuck-execution-cleanup.md— how to unstick a workflow whose lock is legitimately stale.