OOM kill during planning - pg_terminate_backend is ignored

Started by Floris Van Nee19 days ago3 messageshackers
Jump to latest
#1Floris Van Nee
florisvannee@Optiver.com

Hi,

We had a backend OOM-killed in production, bringing down the database.
It turned out to be an analytics query generated by an LLM, and nothing
was ever executed - it was an EXPLAIN (FORMAT JSON).
All of the work was in the planner.

It reached 2 TB RSS over about an hour.  pg_terminate_backend() was issued more
than 20 minutes before the OOM killer ended it, without effect.

We analyzed the query and since it was generated by an LLM it was obviously
complex and not written very well. Still, it was only ~10kB so it's not outside
of the realm of possibilities that such a query is ever executed by anyone. We
traced the issue to a reproducible scenario. A query with the following pattern
is the problem:

  EXPLAIN (SUMMARY ON)
  WITH c0 AS (SELECT relpages::float8 AS x FROM pg_class),
  c1 AS (SELECT (x + x + x + x) AS x FROM c0),
  c2 AS (SELECT (x + x + x + x) AS x FROM c1),
  ... c11 ...
  SELECT x FROM c11;

Planning time grows ~4.2x per level, matching the fanout:

  6 levels      7.7 ms
  7 levels     31.1 ms
  8 levels    134.2 ms
  9 levels    613.9 ms
  10 levels  2570.7 ms

At 12 levels with statement_timeout = '2s':

  t=4s   backend active, 100% CPU, 3346 MB   (timeout expired 2s ago)
  pg_cancel_backend()    -> true, ignored
  t=8s   100% CPU, 6376 MB
  pg_terminate_backend() -> true, then ignored for a further 33.7s
                            while RSS grew to 25.7 GB
  41s wall in total, for a statement with a 2s timeout

It seems like there's two issues here:

* The query not checking for interrupts from pg_terminate_backend for a long
  time - in the real-world case for long enough for the database to get OOM killed.

* A relatively simple query being allowed to consume this much memory in the
  first place. Should there be a bound on how much memory a query can consume during
  planning? It seems like I can get any arbitrary database to get an OOM kill (if OOM
  kill is configured) with a query like this.

Below I've put some analysis from Claude after it ran the reproducer on a local db.
I haven't verified those code paths, but it might help someone on the list here,
so I have included it.

-Floris

From Claude:

There is exactly one CHECK_FOR_INTERRUPTS() on this path, at the top of
pull_up_subqueries_recurse() (prepjointree.c:1278).  It fires once per subquery
level, and all the expensive work happens between two consecutive checks.  In
total src/backend/optimizer/ contains five: that one, two in predtest.c, and
two in pathnode.c's add_path()/add_partial_path().  src/backend/nodes/ contains
none at all -- no walker, mutator or copy function has one.

Making that one check finer-grained would not be sufficient.  Sampling the
stack through one 12-level run shows pull-up finishing with roughly a third of
planning still to go:

  t=6s .. 41s   pull_up_subqueries_recurse
  t=43s .. 50s  eval_const_expressions
  t=51s .. 53s  convert_saop_to_hashed_saop
  t=54s .. 56s  query_planner
  t=58s .. 59s  grouping_planner
  t=61s .. 62s  set_plan_references / fix_scan_expr
  (then executor startup)

Everything after pull-up lives in clauses.c, planner.c, planmain.c and
setrefs.c, which have no CHECK_FOR_INTERRUPTS between them, so ~20s of that run
is unresponsive no matter how often the pull-up recursion checks.  (gdb attach
inflates the total; the proportions are the point.)

I am not sure what the right fix is.  CHECK_FOR_INTERRUPTS() in
expression_tree_walker_impl()/expression_tree_mutator_impl() and
copyObjectImpl() would cover it, but those are relatively hot; an
amortised check every N nodes would be cheaper.  Separately,
nothing bounds planner memory in aggregate, so there is no ceiling short of the
OOM killer -- but that seems like its own thread.

#2Andres Freund
andres@anarazel.de
In reply to: Floris Van Nee (#1)
Re: OOM kill during planning - pg_terminate_backend is ignored

Hi,

On 2026-09-01 19:53:05 +0000, Floris Van Nee wrote:

I am not sure what the right fix is.  CHECK_FOR_INTERRUPTS() in
expression_tree_walker_impl()/expression_tree_mutator_impl() and
copyObjectImpl() would cover it, but those are relatively hot; an
amortised check every N nodes would be cheaper.

I think we'd need a separation between the function to start initiating a tree
walk/mutation and the function called for every recursion level. Then we can
do the CFI() in the "start a tree walk" function, without having to do it the
expression_tree_walker_impl(), once per processed node.

Separately, nothing bounds planner memory in aggregate, so there is no
ceiling short of the OOM killer -- but that seems like its own thread.

That's a ginormous topic, with lots of work needed to get anywhere.

Greetings,

Andres Freund

#3Floris Van Nee
florisvannee@Optiver.com
In reply to: Andres Freund (#2)
Re: OOM kill during planning - pg_terminate_backend is ignored

Hi Andres,

Thanks for your reply.

I think we'd need a separation between the function to start initiating a tree
walk/mutation and the function called for every recursion level. Then we can
do the CFI() in the "start a tree walk" function, without having to do it the
expression_tree_walker_impl(), once per processed node.

It looks like query_or_expression_tree_walker_impl and
query_or_expression_tree_mutator_impl actually have this property of
only being called when starting a tree walk. Perhaps it's OK to add the
check for interrupts at the start of these two functions? I can create a
patch for this if this is an acceptable approach.

-Floris