Plan #7532 — Serialize & gate n8n container restarts on bms-4
Status: draft (design) · Issue: #7532 · Type: hardening (not a bug fix) Related: #7510 (root cause — fixed by PR #7511), #7509 (Redis rotation), #2489 (crashed-vs-error exporter split), #5817 (keepalive-timeout → recovery sweep).
1. Problem
n8n on bms-4 runs in queue mode (main + 3 workers, image n8n:2.26.3). When the n8n main
container is recreated, any execution that has not yet had its terminal status written is reaped to
status=crashed by the recovery sweep on the next startup. A single recreate therefore orphans
every in-flight execution at once.
Fresh evidence (from #7532): 1729 executions marked crashed in a single instant at
2026-10-06 20:20:17 UTC — the signature of one container recreate catching a large in-flight set.
The work itself had completed; only the DB status was wrong.
The original crash mechanism (throttled main event loop missing the DB keepalive, 5817) is
fixed — PR #7511 raised the main CPU cap 0.5→2.0 and the self-inflicted recovery sweeps stopped
(0 Database ping failed, 0 stuck/crashed over 24 h). What remains is the operator-/deploy-driven
class: a restart issued on purpose (a deploy, a manual bounce) still orphans whatever is in flight,
and nothing prevents two restart sources from overlapping.
1.1 Restart sources that recreate n8n containers on bms-4 (ground truth)
| # | Source | What it does | Serialized today? |
|---|---|---|---|
| 1 | secrets-sync.yml → sync-bms-4 job | docker compose stop n8n n8n-worker-1/2/3 → restart redis → docker compose up -d n8n n8n-worker-1/2/3 (.github/workflows/secrets-sync.yml ~L1683–1701). Triggers on any change to secrets/n8n-bms4.env.sops or secrets/n8n-bms4-gh.env.sops. | Only vs other GH Actions runs of the same workflow (concurrency: secrets-sync). Not vs on-box ops. |
| 2 | bms-4/start.sh | docker compose up -d "$@" (manual full/partial bring-up). | No. |
| 3 | Ad-hoc operator / infra-task / sys-admin session | docker compose up -d …, docker restart bms-4-n8n-1, docker compose up -d --force-recreate …. | No. |
The workflow-level concurrency: secrets-sync guard only prevents two GitHub Actions runs from
overlapping. It does nothing when a sync run and a human docker compose up -d on the box (or two
humans) race — which is exactly the “multiple restart sources overlap” half of #7532. There is no
on-host mutex today.
1.2 Current graceful-shutdown posture
- No
stop_grace_periodon the n8n services inbms-4/docker-compose.yml→ Docker’s default 10 s SIGTERM→SIGKILL window applies. - No
N8N_GRACEFUL_SHUTDOWN_TIMEOUTset → n8n’s own graceful-shutdown window is at its default. restart: always,command: start.
So a recreate gives the main process ~10 s to finalize in-flight work before SIGKILL — often not enough under load, which is why in-flight runs get orphaned instead of finalized.
1.3 Alerting claim — must be validated, likely already handled
The issue states false crashed rows trip N8nWorkflowPersistentlyFailing / failure-spike alerts.
Grounding the code suggests this is already mitigated and should be re-verified before any work:
monitoring/exporters/n8n-bms4-exporter/app.py(#2489) labels each execution by its actual status, socrashedis its own series and never inflatesstatus="error"(test_app.py:: test_crashed_not_counted_as_error).N8nWorkflowPersistentlyFailing(monitoring/prometheus/rules/n8n.ymlL143) fires onn8n_bms4_executions_1h{status="error"} > 3—crashedis excluded by construction (the rule comment at L133 says so explicitly).N8nWorkflowFailureSpike(L95) counts the nativen8n_workflow_execution_duration_seconds_count {status="failed"}metric; recovery-sweep crashes are written straight to the DB and do not emit that native duration metric.
Design consequence: Option D below (suppress/annotate crashed for alerting) is low priority and possibly a no-op. The genuine harm is (a) wrong execution history and (b) operator confusion / on-call noise when someone eyeballs the executions list — not a tripping Prometheus alert. This plan treats D as “verify + document”, not “build”.
2. Goal / success criteria
- A deliberate n8n restart on bms-4 finalizes in-flight executions (or waits for them) instead of
orphaning them to
crashed. - No two restart sources can recreate the n8n containers concurrently — secrets-sync,
start.sh, and manual ops all serialize on a single on-host lock. - secrets-sync stops recreating n8n when the change did not touch an n8n-relevant key (fewer needless restarts → fewer orphan windows).
- The
crashed-vs-alerting behaviour is verified against the live rules and documented, so a future operator does not re-derive it.
3. Options evaluated
Option A — on-host flock mutex around every recreate path ✅ adopt
A single lock file (/var/lock/p24-n8n-restart.lock) guarding every path that recreates n8n
containers on bms-4. The repo already uses this exact idiom (scripts/run-nightly-triage.sh L89,
flock -n 200). Introduce one wrapper script, bms-4/n8n-restart.sh, that:
- takes an exclusive
flockon the lock file (blocking, with a timeout — not-n, because a deploy should wait for an in-progress deploy rather than skip), - performs the ordered recreate (the redis-first → n8n sequence already in secrets-sync),
- releases the lock on exit.
secrets-sync.yml and bms-4/start.sh call this wrapper instead of open-coding docker compose up -d n8n …. Manual ops are documented to call it too.
Concrete lock parameters (resolves Risk gap 1): block with a bounded timeout rather than -n
skip — flock -w 600 <fd>. The 600 s (10 min) ceiling is chosen to exceed the worst-case serialized
recreate: main graceful drain (≤120 s, §B) + worker drain (≤60 s) + redis bounce + the existing
12×5 s (≈60 s) n8n verification loop + the 12×5 s pinbox-mongo-proxy loop ≈ 5 min, doubled for
margin. On timeout the wrapper does not force through: it logs, fires the Discord infra-scripts
error webhook, and exits non-zero (the caller — secrets-sync — then fails loud, same posture as the
existing n8n-verification failure at .github/workflows/secrets-sync.yml ~L1738). The lock fd is
released via trap '…' EXIT so a crashed wrapper never wedges the lock. flock on a lock file
(not rm-ing it) means a dead holder’s lock auto-releases when its fd closes — no stale lock file to
clean up.
Pros: cheap, proven idiom, directly closes the “sources overlap” gap the GH concurrency guard
can’t. Cons: manual docker restart outside the wrapper still bypasses it — mitigated by docs + by
making the wrapper the single obvious entry point (see the emergency-bypass note in §4.6).
Option B — graceful drain before recreate ✅ adopt (highest value)
Give n8n time to finalize in-flight executions before SIGKILL:
- Add
stop_grace_periodto the n8n main (and workers) inbms-4/docker-compose.yml, large enough to cover a normal execution tail (candidate: 120 s main, 60 s workers — tune against observed execution durations). - Confirm n8n honours SIGTERM graceful shutdown in queue mode and finalizes/leaves-replayable the
in-flight set; set
N8N_GRACEFUL_SHUTDOWN_TIMEOUTconsistently withstop_grace_periodif the version respects it. - The wrapper from Option A issues
docker compose stop -t <grace>(or relies on the composestop_grace_period) so the drain window is actually honoured on recreate.
Pros: attacks the actual root of the 1729-crashed event — orphaning on a deliberate restart. Cons: longer restart wall-time; a pathologically long-running execution can still exceed the grace window (acceptable — bounded, and far better than an unconditional 10 s).
Option C — reduce needless recreates in secrets-sync ✅ adopt (scoped)
Today sync-bms-4 recreates the whole n8n set on any n8n-bms4*.env.sops change. Mirror the
fingerprint-and-skip pattern already used in the same job for claude-session-manager
(.github/workflows/secrets-sync.yml ~L1490–1534: “restart the service ONLY when a value actually
changed”).
Fingerprint the RENDERED env, not a single SOPS file (resolves Risk gap 2). The running n8n
containers’ env is assembled from five sources, so a fingerprint keyed on n8n-bms4.env.sops
alone would silently miss a changed credential and ship it stale — exactly the #7152 drift class. The
correct key set to fingerprint is the n8n-relevant keys in the final rendered
/opt/p24-infra/bms-4/.env and /opt/p24-infra/bms-4/n8n-bms4-gh.env, which sync-bms-4 builds
from:
| Source SOPS file | n8n-relevant key(s) it contributes |
|---|---|
secrets/n8n-bms4.env.sops | REDIS_PASSWORD, N8N_ENCRYPTION_KEY, N8N_DB_PASSWORD, TELEGRAM_BOT_TOKEN, GROQ_API_KEY, ATRAX_*, CLAUDE_SESSION_MANAGER_KEY, … (primary) |
secrets/monitoring.env.sops (append ~L1248) | PROMETHEUS_BASIC_AUTH_USER, PROMETHEUS_BASIC_AUTH_PASSWORD |
secrets/pinbox24-w4-auth.env.sops (append ~L1265) | REDIS_BRIDGE_KEY |
secrets/et-operational-platform.env.sops (re-assert ~L1292) | PINBOX24_MONGODB_URI (canonical) |
secrets/n8n-bms4-gh.env.sops (separate install) | GH_TOKEN, GITHUB_PAT_* |
Implementation: compute a hash of the n8n-relevant lines of the final .env + n8n-bms4-gh.env
after all appends/re-asserts complete and before the install overwrites the live files; compare
to a hash of the current live files; recreate n8n only when they differ. Keyed on the rendered output,
the guard is source-agnostic — adding a 6th append later needs no allow-list edit, because whatever
lands in the rendered .env is fingerprinted. Print key NAMES / the hash only, never values
(mirrors the existing claude-session-manager block). Prefer targeted --no-deps <service> where only
one service’s key changed.
Pros: removes a large fraction of restarts outright; robust to the multi-source assembly. Cons: fingerprinting the whole rendered n8n-key set is slightly coarser than per-service precision — acceptable, and far safer than an under-inclusive per-file allow-list.
Option D — suppress / annotate post-restart crashed rows for alerting ⚠️ verify only, do not build
Per §1.3 the exporter and rules already exclude crashed from the failing/spike alerts. Action:
verify this on the live stack (confirm no alert actually fired on the 2026-10-06 20:20 event) and, if
confirmed, document it. Only if a real alert is found firing on crashed do we design a suppression —
e.g. a Prometheus annotation/inhibition keyed on a restart marker. No build work is planned unless the
verification contradicts the code.
4. Recommended design
Adopt B + A + C as the core; D = verify + document.
Ordering by value: B (graceful drain) removes the orphaning on any single restart; A (flock) removes the overlap between sources; C (fewer recreates) shrinks how often the window is opened at all. Each is independently useful and independently shippable.
4.1 Changes required
All rows below ship in the SINGLE implementation PR/task (resolves Completeness gap) — there is
no separate follow-up. The dev_r_services row is written as part of the same implementation task
(compliance is mandatory in the same change per CLAUDE.md §Compliance, not deferred), and every doc
edit is committed in the implementation PR alongside the code.
| File / artefact | Change | Ships in impl. PR? | Why |
|---|---|---|---|
bms-4/docker-compose.yml | Add stop_grace_period: to n8n (and each n8n-worker-*); set N8N_GRACEFUL_SHUTDOWN_TIMEOUT consistently if honoured by 2.26.3 | yes | Option B — finalize in-flight before SIGKILL |
bms-4/n8n-restart.sh (new) | flock -w 600-guarded wrapper performing the ordered redis→n8n recreate with docker compose stop -t <grace>; trap EXIT fd release; Discord error-notify on failure/timeout (CLAUDE.md §Error Notification Standard) | yes | Options A + B — single serialized entry point |
.github/workflows/secrets-sync.yml (sync-bms-4) | Call bms-4/n8n-restart.sh instead of the inline stop/up block; wrap in the rendered-env fingerprint-and-skip guard (§C) | yes | Options A + C |
bms-4/start.sh | Route the n8n bring-up through bms-4/n8n-restart.sh (or share the lock) | yes | Option A — close the manual path |
docs/n8n-operations.md | Document the single restart entry point, the lock + flock -w 600 timeout, the grace window, the §4.6 emergency-bypass + rollback, and the verified crashed-vs-alerting behaviour | yes | Option D + operability |
dev_r_services (Supabase) row | Register bms-4/n8n-restart.sh as a new operational script (compliance_workbook='yes', workbook_url → docs/n8n-operations.md) | yes (same task) | CLAUDE.md §Compliance — mandatory in-change |
Note: pushing the
.github/workflows/secrets-sync.ymledit requires theworkflow-scope token path (docs/playbooks/worker-push-workflow-files.md) — the implementer (aninfra-task/sys-adminworker on bms-4) must use theGH_TOKENPAT push helper, not the defaultghOAuth login.
4.2 Regression risks
- Longer deploy time. A graceful drain + serialized lock lengthens
sync-bms-4. The existing verification loop (12×5 s) already tolerates slow startup; confirm the GH job timeout covers the new worst case. - Lock starvation / stuck lock. If the wrapper dies holding the lock, later restarts block. Use a
blocking
flockwith a timeout and release viatrap … EXIT; log + Discord-notify on timeout. - Grace window too long for a hung execution.
stop_grace_periodis an upper bound — SIGKILL still fires after it, so a hung run cannot wedge a deploy forever. - secrets-sync skip-guard too aggressive. If the n8n-relevant key allow-list misses a key, a rotated credential won’t reach the running container until the next real n8n restart. Keep the list broad and documented; a missed-key case is a stale-credential bug, not an outage.
restart: alwaysinteraction. Verify the wrapper’sstop+upsequence doesn’t race Docker’s own restart policy.
4.3 DB schema changes
None.
4.4 Rollback & operator emergency path (resolves Risk gap 3)
Emergency restart when the lock is held / the wrapper hangs. The wrapper is the normal entry
point, not a cage. An operator who needs n8n back now and finds the lock held has two documented
escapes (both to be written into docs/n8n-operations.md):
- Inspect the holder first:
fuser /var/lock/p24-n8n-restart.lock(orlsof) → if it is a live, legitimate deploy, wait (that is the intended serialization). - Genuine emergency only: run the recreate directly, bypassing the lock —
cd /opt/p24-infra/bms-4 && docker compose up -d --force-recreate n8n n8n-worker-1 n8n-worker-2 n8n-worker-3. This knowingly forgoes serialization; it is the last resort and is logged as such. (flockon an fd auto-releases when the holder dies, so there is normally nothing to “clear”; only use a manual bypass when a holder is genuinely wedged.)
Rollback of the changes themselves (each independently revertible):
- Compose change: revert the
stop_grace_period/N8N_GRACEFUL_SHUTDOWN_TIMEOUTlines inbms-4/docker-compose.yml, thendocker compose up -d --force-recreate n8n n8n-worker-*. Returns to the current Docker-default 10 s window immediately. - Wrapper + start.sh: revert the two files; the inline
stop/upblock restored instart.shkeeps bring-up working with zero wrapper dependency. - secrets-sync workflow: revert the
sync-bms-4edit to restore the always-recreate inline block, then re-runsecrets-sync.yml. Because the fingerprint guard only skips a recreate, the fallback of the old behaviour is strictly “recreate more often”, never “fail to deploy”. - No DB migration to roll back; the
dev_r_servicesrow is a benign metadata entry.
4.5 Alerting during the longer drain (addresses Risk sub-point)
The graceful drain lengthens the window where n8n containers are briefly down during a recreate.
N8nWorkerDown is already for: 10m / warning and N8nAllWorkersDown is for: 3m / critical
(monitoring/prometheus/rules/n8n.yml). A 120 s main + 60 s worker drain stays inside the 3 m
N8nAllWorkersDown window, so no new false page is expected — but the implementer must confirm the
total serialized recreate wall-time (drain + verify loops) stays under 3 m, or raise
N8nAllWorkersDown’s for: accordingly. Noted as an implementation acceptance check.
4.6 Manual test checklist
- Trigger
bms-4/n8n-restart.shwhile a known long execution is in flight → it finalizes (success), is not markedcrashed. - Launch two
n8n-restart.shinvocations concurrently → the second waits for the first (lock held), neither leaves orphaned/hash-suffixed containers. -
secrets-syncrun that changes a non-n8n key inn8n-bms4.env.sops→ no n8n recreate. -
secrets-syncrun that changes an n8n key → n8n recreated exactly once, all 4 containers reachrunning(existing verification loop still passes). - Confirm against live Prometheus that the 2026-10-06 20:20 crashed burst did not fire
N8nWorkflowPersistentlyFailing/N8nWorkflowFailureSpike(validates §1.3 / Option D). - Discord error webhook fires if the wrapper fails or the lock times out.
4.5 Dependencies
- Independent of #7509 (Redis rotation). The redis-first ordering already lives in secrets-sync and is preserved by the wrapper.
5. Scope / role boundary
This is a design plan (this issue is plan-labelled). The deliverable here is this document + the
## Code-change-design comment + /review-plan. The implementation lands server-side changes
(docker-compose.yml on bms-4, a new on-host wrapper script, a secrets-sync.yml workflow edit) and
is therefore an infra-task/sys-admin job, not a dev-coder one — the implementation issue
must be dispatched with server_preference='bms-4' so it runs where production SSH + Docker live.
File-overlap signature: docs/plans/plan-7532-serialize-n8n-restarts.md
files_to_change (implementation): bms-4/docker-compose.yml, bms-4/n8n-restart.sh, .github/workflows/secrets-sync.yml, bms-4/start.sh, docs/n8n-operations.md