Reported symptoms
At 14:22 every connection to the primary was dropped simultaneously and
new connections were refused for eleven seconds. Every service logged
connection reset by peer at the same instant.
Uncommitted work in unrelated transactions was rolled back. The cluster returned on its own, with no operator action and no data loss.
Host monitoring shows a memory spike immediately before the event and normal memory afterwards.
One analyst reports that a query they had been running for several
minutes returned server closed the connection unexpectedly.
The same event has now happened four times in six weeks, always during business hours, always briefly.
Evidence provided
$ grep -A3 'terminated by signal 9' /var/log/postgresql/postgresql-18-main.log2026-08-28 08:04:23.936 UTC [1] LOG: client backend (PID 119) was terminated by signal 9: Killed
2026-08-28 08:04:23.936 UTC [1] DETAIL: Failed process was running:
SELECT length(array_to_string(array_agg(pad), ',')) FROM big;
2026-08-28 08:04:23.936 UTC [1] LOG: terminating any other active server processes
2026-08-28 08:04:23.939 UTC [1] LOG: all server processes terminated; reinitializing$ grep -E 'recovery mode|redo|ready to accept' /var/log/postgresql/postgresql-18-main.log2026-08-28 08:04:23.996 UTC [134] FATAL: the database system is in recovery mode
2026-08-28 08:04:23.997 UTC [125] LOG: redo starts at 0/222A0DC8
2026-08-28 08:04:24.806 UTC [125] LOG: redo done at 0/4AFFEC38 system usage: CPU: user: 0.33 s, system: 0.45 s, elapsed: 0.80 s
2026-08-28 08:04:25.126 UTC [1] LOG: database system is ready to accept connectionsoom_score_adj is 0 for the postmaster and 0 for every backend.
And a result that narrows the search considerably:
$ psql -c "SET work_mem = '2GB'; SELECT count(*) FROM (SELECT * FROM big ORDER BY pad, id) s;"SET
count
---------
2000000
(1 row)Work the evidence before reading on
- One backend was killed. Why did every other session die?
- A 2 GB
work_memin a 768 MB container completed a two-million-row sort. What does that rule out? - What kind of query cannot spill?
oom_score_adjis 0 everywhere. What does the kernel choose on?
Root cause
One SIGKILL costs the whole cluster
work_mem was not the culprit
Sorts and hash aggregates spill to disk when they exceed work_mem.
A sort of two million rows with work_mem at 2 GB inside a 768 MB
container completed successfully by spilling.
work_mem is a target, not a reservation. A large sort is not what
exhausts memory.
The kernel chose on footprint alone
oom_score_adj is 0 for the postmaster and 0 for every backend, so the
kernel picks purely by memory use. That usually means the guilty backend
— which is fortunate, and not guaranteed. If the postmaster is chosen the
outcome is worse and the log is less informative.
Resolution
Identify the query. PostgreSQL records it, and that is what makes this incident actionable:
grep -A3 'terminated by signal 9' /var/log/postgresql/postgresql-18-main.log
Confirm the shape of the demand rather than assuming:
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
A plan whose top node is an aggregate producing one enormous value has no
spill point. A Sort reporting external merge, or a HashAggregate
reporting Batches: 4, is spilling and is not your problem.
Bound what a single session may consume, per role:
ALTER ROLE analytics SET work_mem = '32MB';
ALTER ROLE analytics SET statement_timeout = '5min';
ALTER ROLE analytics SET temp_file_limit = '10GB';
statement_timeout is the control that actually helps here, because an
unbounded allocation is unbounded in time as well as size — this query
had been running for several minutes. Nothing else will stop it.
Protect the postmaster from the kernel’s choice:
# /etc/systemd/system/postgresql@.service.d/oom.conf
[Service]
OOMScoreAdjust=-900
Then check /proc/<pid>/oom_score_adj for the postmaster and a backend
and confirm they differ. If they do not, the setting is not doing what
you intended.
Set the host’s overcommit policy deliberately:
sysctl -w vm.overcommit_memory=2
sysctl -w vm.overcommit_ratio=80
With strict overcommit the kernel refuses the allocation instead of granting it and killing later, so the query fails with an ordinary error and the cluster stays up.
Verification
terminated by signal 9 does not reappear. Grep for it as a standing
check, not only after an incident.
The identified query now fails with an ordinary error rather than killing the cluster. Run it deliberately and confirm which happens.
Other sessions survive. This is the property being bought and it must be observed directly: open a second session holding an open transaction, run the offending query in the first, and confirm the second is alive afterwards.
The postmaster’s oom_score_adj differs from a backend’s:
cat /proc/$(pgrep -f 'postgres.*-D' | head -1)/oom_score_adj
Host memory has headroom at peak concurrency, with shared_buffers, the
per-role work_mem ceiling and the connection count in one written
calculation.
Crash recovery time is known. If this happens again you want to know whether it costs 11 seconds or four minutes. Measure it once, in a controlled test.
Prevention
Alert on terminated by signal 9. Unambiguous, names the query, and
four occurrences went uninvestigated because nobody was watching.
Understand that one killed backend restarts the cluster.
Set statement_timeout per role. The only control that bounds an
allocation which cannot spill.
Do not assume work_mem is the culprit. Look for array_agg,
string_agg, json_agg over unbounded input.
Protect the postmaster with OOMScoreAdjust.
Choose the overcommit policy deliberately.
Size memory with a written calculation: shared_buffers plus
max_connections x work_mem x memory_nodes_per_plan plus the operating
system’s needs, against physical RAM.
Give analytics its own role with its own limits, and preferably its own replica. A reporting workload and an OLTP workload should not share a memory budget or a failure domain.