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.

QuestionAnswer 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 NOT DECR, 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:rechnungen
  • w4ingest:lock:gutshrifts
  • w4ingest: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

FeatureSET NX EX (would-be ideal)INCR + EXPIRE (this pattern)
Atomic “first caller wins”yesyes (INCR is atomic)
Sets TTL in the same opyesno — second call needed
TTL race window (crash between INCR and EXPIRE)nonesmall: if the workflow crashes AFTER INCR but BEFORE EXPIRE, the key has no TTL and never expires
Requires n8n Code node with ioredisyes — n8n Redis node has no compound SET NX EXno — pure n8n Redis node ops
Works in n8n Code sandboxno (require('ioredis') blocked)yes

The INCR-then-EXPIRE race is the only real downside. Mitigations:

  1. The two nodes execute back-to-back in-process; a crash between them is possible but rare.
  2. A watchdog n8n workflow (out of scope for this pattern) can SCAN for keys matching <workflow-slug>:lock:* with TTL == -1 and set a TTL — belt-and-braces if this becomes a real problem.
  3. 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

ScenarioWhat happensRecovery
Winner runs normally, DEL fires on done branchLock released immediately.Next trigger sees INCR → 1 and runs.
Winner crashes AFTER EXPIRE set but BEFORE doneLock 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 EXPIREKey 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 DELsWinner’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 / DECRsSame as above — releases winner’s lock or extends it wrongly.Same prevention: NO Redis ops in loser branch.
Redis restarts mid-runKey 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)

  1. Confirm the workflow is active:false before editing (it currently is — safe edit window).
  2. Snapshot current JSON: GET /rest/workflows/MUjApqruo6H88ebw → commit to n8n-workflows/w4-direct-api-ingestion.pre-4008.json so a rollback is one file away.
  3. Add the 5 lock nodes + 1 heartbeat per branch (× 3 branches) via the n8n REST API.
  4. Export the modified JSON to n8n-workflows/w4-direct-api-ingestion.json — this is the first time this workflow lives in git.
  5. 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:rechnungen should show a decreasing value that jumps back to ~600 s at every heartbeat.
  6. Kill the winner mid-run (docker restart n8n-worker) → confirm TTL counts down to zero within 10 min and the next scheduled fire wins.
  7. 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.
  8. If any test is red → leave the workflow active:false and 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 scheduleTrigger branch has an INCRIF gate immediately after it.
  • Lock key is <workflow-slug>:lock:<resource-id> — stable across workflow renames.
  • Winner branch: EXPIRE key 600 executes exactly once, right after the IF-TRUE output.
  • Winner branch: EXPIRE key 600 heartbeat inside the loop, per unit of work.
  • Winner branch: DEL key on the loop’s DONE output only (not the iteration output).
  • Loser branch: contains a NoOp and 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.

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 < 30 AND the owning workflow has an execution in running state 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.