The N8nWorkflowPersistentlyFailing alert fires for an n8n workflow (e.g. N errors/1h).
The n8n executions list shows a run of consecutive crashed executions (status crashed,
finished=false), typically with stoppedAt timestamps clustered into a few identical moments
rather than spread evenly.
This pattern is NOT a workflow-logic failure — it is in-flight executions being orphaned
when n8n loses (and re-establishes) its connection to its Postgres backing store. On each reconnect,
n8n’s execution-recovery sweep marks every still-”running” execution as crashed.
How to recognise it (vs. a real workflow bug)
Run on the n8n host (bms-4). Get the API key by NAME only — never print it:
N8N_KEY=$(grep -m1 '^BMS4_N8N_API_KEY=' /opt/p24-infra/bms-4/.env | cut -d= -f2- | tr -d '"')BASE="http://localhost:5678"WF=swdqVHDAj4kTeSyV # workflow id from the alert's workflow_id label
Status history — look for many crashed with batched stoppedAt:
curl -s "$BASE/api/v1/executions?workflowId=$WF&limit=30&includeData=false" \ -H "X-N8N-API-KEY: $N8N_KEY" \ | python3 -c 'import json,sys;[print(e["id"],e["status"],e["startedAt"],"->",e["stoppedAt"]) for e in json.load(sys.stdin)["data"]]'
Per-node trace of a crashed run — confirms the workflow did NOT fail on a node. A genuine
orphan shows the first node(s) succeeding then nothing, with no node-level error:
Connection exhaustion: docker exec bms-4-n8n-postgres-1 psql -U n8n -d n8n -tAc "SELECT count(*) FROM pg_stat_activity;" vs SHOW max_connections; (was 13/100)
Postgres restart: docker ps -a --filter name=n8n-postgres (was up 3 days)
The Postgres duplicate key … PK_b21ace2e… errors are recovery artifacts, not the cause.
If steps 1–3 match, this is the DB-connection-drop pattern — proceed to remediation. If a crashed
run shows a real node-level error, this playbook does not apply — triage the node error instead.
Remediation
If the workflow is already healthy again (latest executions are success), no live action is
needed — a full n8n stack recreate or the next clean reconnect clears the orphan backlog. Verify:
curl -s "$BASE/api/v1/executions?workflowId=$WF&limit=6&includeData=false" -H "X-N8N-API-KEY: $N8N_KEY" \ | python3 -c 'import json,sys;[print(e["id"],e["status"],e["startedAt"]) for e in json.load(sys.stdin)["data"]]'
If executions are still crashing, recreate the n8n stack to reset the Postgres connection pool
— but restart the workers STAGGERED, never all at once. A simultaneous cold start of all three
workers makes them CPU-peg replaying the shared event-log/crashed backlog on the n8n_data
volume; they stop serving /metrics and trip N8nWorkerDown (#5598, #5813, #5817). Drain the
crashed backlog first (see §Draining the crashed backlog) so there is little to replay, then:
# PLAYBOOK: n8n-crashed-executions-db-connection-drop.mdcd /opt/p24-infra/bms-4docker compose up -d --force-recreate n8n # main first; wait for healthyfor w in 1 2 3; do docker compose up -d --force-recreate n8n-worker-$w sleep 45 # let each worker finish recovery before the nextdone
Check for stranded messages (for WA pipelines: claim_wa_messages marks rows processing
before a crash). The wa-processing-watchdog workflow auto-resets rows stuck processing >30 min
to processingError; verify nothing is stranded:
# via Supabase REST (service key by NAME only) — counts per statusfor s in new processing processingError; do curl -s "$SUPA_HB_URL/rest/v1/p24_whatsapp_messages?status=eq.$s&select=id" \ -H "apikey: $SUPA_HB_KEY" -H "Authorization: Bearer $SUPA_HB_KEY" \ -H "Prefer: count=exact" -H "Range: 0-0" -I 2>/dev/null | grep -i content-rangedone
Rows reset to processingError are terminal (not re-claimed). If user-facing tickets were
lost, requeue by hand: UPDATE p24_whatsapp_messages SET status='new' WHERE status='processingError' AND processing_started_at >= '<window-start>'.
Prevention
executionTimeout on at-risk scheduled workflows (added to wa-ai-to-inbox in #2253:
settings.executionTimeout = 120). Bounds an orphaned/hung execution to 120 s instead of
lingering until the next recovery sweep, and prevents overlap with the 5-min schedule. Choose a
value well above the observed legit runtime (≤1.7 s for wa-ai-to-inbox) and below the schedule
interval. Apply to live via the n8n API PUT (see .claude/commands/n8n.md), then re-export the
snapshot.
If the n8n→Postgres drops become frequent (not the transient one-off seen in #2253), escalate as a
capacity / DB-stability issue: inspect Postgres checkpoint duration, container resource limits,
and the inter-container network, and consider raising the n8n DB connection-pool timeout.
Frequent-drop / recovery-storm root cause (#5817, 2026-08-06)
When the drops recur every ~1–2 h (14 in 24 h on bms-4) the signature is always
Database ping failed (1/3): Database connection timed out → Database connection recovered2–4 s later — the first keepalive ping times out and the retry succeeds. That is not a
real connection loss; it is a brief stall while the ping cannot get a working DB connection. Three
compounding causes, all confirmed on bms-4:
DB connection pool too small. n8n’s DB_POSTGRESDB_POOL_SIZE was unset → default 2 on the
main instance and every worker. When both pool connections are momentarily busy (a prune DELETE,
an insights aggregation, a slow query) the keepalive ping cannot acquire one within its timeout and
logs ping failed. Fix: set DB_POSTGRESDB_POOL_SIZE explicitly (main 8, workers 4 — done in
bms-4/docker-compose.yml, applies on next container restart).
Postgres untuned. Stock postgres:16.9-alpine runs shared_buffers=128MB against a 2 GB+
execution store. Timed checkpoints (checkpoint_timeout=300s) flush dirty pages in I/O bursts that
briefly stall queries. Fix:command: tuning on n8n-postgres (shared_buffers 512MB,
max_wal_size 2GB, maintenance_work_mem 256MB — done in compose, applies on next postgres recreate).
crashed backlog feeds itself. Each stall’s recovery sweep marks in-flight runs crashed;
the backlog (9,981 rows / 3.2 GB execution_data on 2026-08-06) is then the fuel for the cold-start
recovery storm that takes workers offline (#5598/#5813). Draining it breaks the loop.
Confirm each: pool size docker exec bms-4-n8n-1 printenv DB_POSTGRESDB_POOL_SIZE;
shared_buffersdocker exec bms-4-n8n-postgres-1 psql -U n8n -d n8n -tAc "SHOW shared_buffers;";
backlog … -tAc "SELECT status,count(*) FROM execution_entity GROUP BY 1;".
Observability gap (noted #5817):n8n-postgres has log_checkpoints=on but
logging_collector=off and its stderr is not reaching docker logs — so checkpoint timing/duration
is currently invisible. If deeper checkpoint analysis is needed, fix the container log path first.
Draining the crashed backlog
crashed executions are terminal orphans — they hold no recoverable work and are safe to delete.
EXECUTIONS_DATA_PRUNE=true/MAX_AGE=168h only ages them out slowly (7 days), so after a storm the
backlog must be drained by hand. Delete in batches (never one giant DELETE) — a single delete of
thousands of large execution_data TOAST rows generates a WAL burst that itself stalls the ping.
execution_data, execution_metadata, execution_annotations are ON DELETE CASCADE, so deleting
execution_entity is sufficient.
# PLAYBOOK: n8n-crashed-executions-db-connection-drop.mdPSQL() { docker exec bms-4-n8n-postgres-1 psql -U n8n -d n8n -tAc "$1"; }PSQL "SELECT count(*) FROM execution_entity WHERE status='crashed';" # how manyfor i in $(seq 1 20); do n=$(PSQL "WITH d AS (DELETE FROM execution_entity WHERE id IN ( SELECT id FROM execution_entity WHERE status='crashed' ORDER BY id LIMIT 1000 ) RETURNING 1) SELECT count(*) FROM d;" | tr -d '[:space:]') echo "batch $i: $n"; [ "$n" = "0" ] && break; sleep 1done# Reclaim freed space for reuse + refresh planner stats (plain VACUUM — no exclusive lock):docker exec bms-4-n8n-postgres-1 psql -U n8n -d n8n -c "VACUUM (ANALYZE) execution_data;"docker exec bms-4-n8n-postgres-1 psql -U n8n -d n8n -c "VACUUM (ANALYZE) execution_entity;"
Plain VACUUM marks the pages reusable but does not return disk to the OS (the file stays ~its
peak size and is refilled by new executions). Only run VACUUM FULL execution_data if you must reclaim
disk — it takes an exclusive lock that blocks n8n, so do it in a maintenance window with the stack
paused. On 2026-08-06 the batched drain of 10,061 rows caused 0 new ping failures and DB size fell
3,246 MB → 2,208 MB (row-count fuel removed; residual is reusable free space).
Top backlog contributors are the highest-frequency scheduled workflows — bound them with
executionTimeout per §Prevention so a single orphaned run cannot linger until the next sweep
(2026-08-06 top: mezmo-alert-router 6,014, atrax/kravag-scheduled-fleet-updates 1,304,
p24-content-orchestrator 749, wa-ai-to-inbox 747).