docs: operational hardening — bound memory, stop silent crash loops - #74
Merged
Merged
Conversation
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>
Diego Colombo (colombod)
approved these changes
Aug 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivating Incident (Real, Measured)
On a production-scale host (121 GB RAM / 3.7 TB disk):
crash recovery respawned 94/94 drainers, took ~4 minutes, peaked at 43.9 GB RSSRestart=on-failurewith noStartLimitBurst→ 16 worker boots per 30 minutes for two days, serving zero requests, burning 9.5 CPU-hoursChanges
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.mddocker runnow carries--memory/--memory-swapplus explicit heap/pagecache env varsStartLimitIntervalSec,StartLimitBurst,MemoryMax,MemoryHighREADME.mdNew "Resource Limits and Crash-Loop Guards" section linking
docs/operational-hardening.md.server-config.example.yamlwrite_concurrencycomment 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"queues_pathoperational warningdocs/local-development.mdOne 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-communitywithNEO4J_server_memory_heap_max__size=512m; confirmed it landed asserver.memory.heap.max_size=512min neo4j.conf and-Xmx524288kon the JVM.✅ Container memory limit interaction:
Ran same image with
--memory 2gand no heap env: neo4j.conf got only an auto-computed pagecache size and the JVM carried no-Xmxat 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 verifyon a deliberately-wrong unit printedUnknown 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=20Gproduced 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: 4as 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 ofdocs/operational-hardening.mdand theserver-config.example.yamlcomment are the two places to soften language from "should" to "consider".Generated with Amplifier