Skip to content

docs: operational hardening — bound memory, stop silent crash loops - #74

Merged
Diego Colombo (colombod) merged 2 commits into
mainfrom
docs/operational-hardening
Aug 15, 2026
Merged

docs: operational hardening — bound memory, stop silent crash loops#74
Diego Colombo (colombod) merged 2 commits into
mainfrom
docs/operational-hardening

Conversation

@bkrabach

Copy link
Copy Markdown
Collaborator

Motivating Incident (Real, Measured)

On a production-scale host (121 GB RAM / 3.7 TB disk):

  • The durable queue spool grew to 38 GB / 583 files (largest 4.9 GB) with no signal
  • Neo4j ran with no heap cap and no container memory limit, auto-sized from host RAM → 22 GB
  • On restart: startup logged crash recovery respawned 94/94 drainers, took ~4 minutes, peaked at 43.9 GB RSS
  • Kernel OOM killer SIGKILLed the process
  • The systemd unit had Restart=on-failure with no StartLimitBurst16 worker boots per 30 minutes for two days, serving zero requests, burning 9.5 CPU-hours
  • Only symptom: a graph that silently stopped updating. Nobody noticed for two days.
  • Remediation, verified: 14/14 probes returned 200 over 10 minutes, memory plateaued at 1.2 GB, 1 worker, 0 restarts.

Changes

docs/operational-hardening.md (NEW, 325 lines)

The complete incident narrative, the four operational guards, a detection table ("how to tell this is happening to you"), and an explicit capability/limitation matrix ("what buys you what").

docs/service-setup.md

  • §2's Neo4j docker run now carries --memory/--memory-swap plus explicit heap/pagecache env vars
  • §5's systemd unit heredoc now carries StartLimitIntervalSec, StartLimitBurst, MemoryMax, MemoryHigh
  • §9 gained three troubleshooting rows for OOM/memory pressure scenarios
  • (This repo ships no systemd unit file, only a macOS plist template — this heredoc is the unit template, so guards now apply by default to anyone following the primary install path)

README.md

New "Resource Limits and Crash-Loop Guards" section linking docs/operational-hardening.md.

server-config.example.yaml

  • Expanded write_concurrency comment carrying real evidence from a sibling deployment: "default 8 thundered neo4j's ~20.6GiB tx-memory ceiling on a restart-with-backlog → 307 OOM/hr"
  • Added queues_path operational warning

docs/local-development.md

One bullet naming the deliberate absence of memory limits on dev Neo4j, with a pointer to the operational doc.


Evidence (All Empirically Validated on Real Hosts)

Neo4j heap config propagation:
Started throwaway neo4j:5.26.22-community with NEO4J_server_memory_heap_max__size=512m; confirmed it landed as server.memory.heap.max_size=512m in neo4j.conf and -Xmx524288k on the JVM.

Container memory limit interaction:
Ran same image with --memory 2g and no heap env: neo4j.conf got only an auto-computed pagecache size and the JVM carried no -Xmx at all — which is why the doc recommends both flags, and corroborates the incident (no limit ⇒ sizing reads the whole 121 GB host).

systemd configuration validation:
systemd-analyze verify on a deliberately-wrong unit printed Unknown key name 'StartLimitIntervalSec' in section 'Service', ignoring.` while exiting 0 (silent misconfiguration). It also flagged three unrelated real units on the test box making that exact mistake. The unit as written in service-setup.md was extracted and re-verified clean.

systemd memory limit precision:
systemd-run --user -p MemoryMax=24G -p MemoryHigh=20G produced exactly the byte values quoted in the doc.

Bash and config syntax:
All 44 bash blocks across the four docs pass bash -n; the YAML still parses; all markdown link targets resolve.


Note for Maintainer

The docs currently recommend write_concurrency: 4 as a should, not a consider. Evidence: one sibling deployment's config comment (above) plus a second host carrying the same hazard untested. If you'd rather this read as a recommendation than an instruction, §4 of docs/operational-hardening.md and the server-config.example.yaml comment are the two places to soften language from "should" to "consider".


Generated with Amplifier

Incident: On a 121 GB RAM / 3.7 TB disk host, the durable queue spool grew
to 38 GB / 583 files (largest 4.9 GB) with no signal. Neo4j ran with no heap
cap and no container memory limit, auto-sizing from host RAM, growing to 22 GB.
On restart, the startup logged 'crash recovery respawned 94/94 drainers',
took ~4 minutes, peaked at 43.9 GB RSS, and was SIGKILLed by the kernel OOM
killer. The systemd unit had Restart=on-failure with no StartLimitBurst,
causing 16 worker boots per 30 minutes for two days, serving zero requests.

Solution: Four operational guards:
1. Neo4j heap cap + container memory limit (both required)
2. systemd MemoryMax / MemoryHigh for the service container
3. systemd StartLimitIntervalSec / StartLimitBurst to prevent crash loops
4. Spool handling guidance and write_concurrency tuning

Changes:
- docs/operational-hardening.md (NEW, 325 lines): The incident, the four
  guards, detection table, and explicit capability/limitation matrix
- docs/service-setup.md: Neo4j docker run now carries --memory/--memory-swap
  plus explicit heap/pagecache env; systemd unit heredoc carries start-limit
  and memory guards; §9 gained three troubleshooting rows
- README.md: New 'Resource Limits and Crash-Loop Guards' section linking the
  new doc
- server-config.example.yaml: Expanded write_concurrency comment with real
  evidence; queues_path operational warning
- docs/local-development.md: One bullet naming the deliberate absence of
  memory limit on dev Neo4j

Evidence (all empirically validated on real hosts):
- Neo4j 5.26.22-community with NEO4J_server_memory_heap_max__size=512m
  confirmed -Xmx524288k on JVM and server.memory.heap.max_size=512m in conf
- Same image with --memory 2g and no heap env: neo4j.conf got auto-computed
  pagecache but JVM carried no -Xmx (corroborating the incident)
- systemd-analyze verify on deliberately-wrong unit: printed Unknown key error
  but exited 0 (silent misconfiguration); verified unit as written is clean
- systemd-run --user -p MemoryMax=24G -p MemoryHigh=20G produced exact byte
  values quoted
- All 44 bash blocks pass bash -n; YAML parses; all markdown links resolve

Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…ample

The prior commit on this branch landed only the new operational-hardening.md;
the four files that make it discoverable and apply the guards by default were
left uncommitted. This adds them.

- docs/service-setup.md: the Neo4j docker run in §2 now carries
  --memory/--memory-swap plus explicit heap/pagecache env (verified: with
  --memory alone and no heap env, the JVM carries no -Xmx at all and sizing
  reads the whole host). §5's systemd unit heredoc now carries
  StartLimitIntervalSec/StartLimitBurst in [Unit] and MemoryHigh/MemoryMax in
  [Service] - this repo ships no unit file, so that heredoc IS the template.
  §9 gained three troubleshooting rows.

- README.md: new 'Resource Limits and Crash-Loop Guards' section linking the
  new doc. README is the docs index; an unlinked doc is a doc that does not
  exist.

- server-config.example.yaml: expanded write_concurrency comment carrying the
  real evidence (default 8 thundered neo4j's ~20.6GiB tx-memory ceiling on a
  restart-with-backlog), plus a queues_path operational warning. This is where
  an operator meets the setting before hitting the wall.

- docs/local-development.md: names the deliberate absence of a memory limit on
  the dev Neo4j, with a pointer, rather than silently copying the unlimited
  command.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@colombod
Diego Colombo (colombod) merged commit 1fccb85 into main Aug 15, 2026
3 checks passed
@colombod
Diego Colombo (colombod) deleted the docs/operational-hardening branch August 15, 2026 10:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants