Skip to content

fix(query): bound the rollup's untracked allocation, and instrument the rest - #264

Open
vishr wants to merge 2 commits into
mainfrom
fix/bound-duckdb-untracked-memory
Open

vishr wants to merge 2 commits into
mainfrom
fix/bound-duckdb-untracked-memory

Conversation

@vishr

@vishr vishr commented Sep 21, 2026

Copy link
Copy Markdown
Member

Partial progress on #254. This no longer claims to fix the OOM — see below.

What this contains

quantile_contapprox_quantile (verified, real, insufficient)

Holistic aggregates retain every value of every group in a vector on the raw allocator, outside anything memory_limit bounds.

30M rows at memory_limit='100MB': 861–912 MiB, with duckdb_memory() reporting 0.0 MiB. approx_quantile keeps a fixed-size t-digest per group: 8–9 MiB. Same substitution for PERCENTILE_CONT in the anomaly detector, which runs every 60s.

Deployed alone to the live demo host, this did not move the peak: 9.64 → 9.58 GiB. Real unbounded allocation, not the dominant one.

TestServiceRollupLatencyDoesNotAllocatePerRow gates it, verified in both directions: 912 MiB fail on the old expression, 9 MiB pass on the new.

A per-tag fanout_duckdb_memory_bytes gauge

So process_resident_memory_bytes − Σ fanout_duckdb_memory_bytes − go_memstats_heap_sys_bytes is the untracked heap directly. The absence of this series is why several hypotheses were chased on inference before anyone scraped the endpoint.

What I removed from this PR, and why

An earlier revision dropped union_by_name=true from the Parquet views. That was wrong and review caught it.

It worked, spectacularly — peak 9.64 → 4.49 GiB on the live host, flat while batch count grew 1,744 → 6,263. Bind opens every file in the glob and holds a reader and footer per file for the query's life; measured at 510 MiB vs 89 MiB per query on 3,281 live files, with duckdb_memory() reporting an identical 69.0 MiB either way.

But verified on DuckDB 1.5.5:

no union_by_name:   Invalid Input Error: schema mismatch in glob: column "newcol" was
                    read from the original file a.parquet, but could not be found in b.parquet
union_by_name=true: id=1 newcol=x / id=2 newcol=<null>

ensureSchemaBatch writes an empty _schema.batch from the current binary's structs precisely so the glob carries the full column set, and CreateViews names every column explicitly and binds eagerly. So a build that adds a column would fail at NewDuck and the process would refuse to start until every pre-deploy batch aged out.

My claimed mitigation was also wrong: selectCompactionBatches only merges ≥2 batches sharing a day and generation, so old-schema files can survive to retention.

That trades an OOM kill for a process that won't boot. Worse. Reverted.

So the OOM is not fixed

The dominant term is real and measured — per-file bind cost × concurrent binders × live file count. The flag that causes it is load-bearing and cannot simply be removed.

The bounded fix is to bound the file count. Compaction is losing badly to ingest: 1,744 → 6,263 live batches in 38 minutes on the demo. At ~128 KB/file of bind cost, holding the batch count near a few hundred keeps per-query cost in the tens of MB and makes the ceiling memory_limit plus a fixed margin. That's a separate change: raise compaction throughput, or add ingest backpressure.

Review findings addressed

  • RefreshDuckDBMemory had zero callers — the metric family exported nothing, indistinguishable from "DuckDB holds nothing". Now a pull-based collector.
  • releaseMemoryGauges was assigned but never invoked; Close now releases it, so a closure over a closed pool can't freeze the gauges at stale values.
  • Reset() before republish, so a tag that stops appearing stops being reported.
  • rows.Err() checked rather than publishing a partial sum as truth.
  • Doc comment no longer displaces SetDuckDBPoolSource's.
  • Detector comment moved onto the query it describes.
  • RSS read from /proc/self/statm where available, so the gate doesn't depend on a ps supporting -o/-p; long scan skipped under -short.

Verified and not changed: DuckDB dedupes the two identical approx_quantile expressions — EXPLAIN shows two, not four.

just check passes.

The process was OOM-killed repeatedly on memory DuckDB never reported.
memory_limit bounds the buffer pool and nothing else, so two allocations
outside it grew without a ceiling and without a pressure signal.

union_by_name=true on the telemetry views is the larger one. It makes
bind open every file in the glob and hold a reader and footer per file
for the life of the query, on the raw allocator. Measured against 3,281
live batches: 510 MiB per query with it, 89 MiB without, while
duckdb_memory() reported the same 69.0 MiB in both cases. Every concurrent
binder pays it -- four read connections plus the writer -- and the rollup
pass binds three globs, so the cost scaled with batch count rather than
with query size. That is why process memory tracked how many files
existed rather than how much work was being done.

It bought nothing. Every batch is written from the same Go struct by one
binary, and a live check across all 3,281 files found a single distinct
schema. DuckDB matches columns by name across a glob either way; the flag
only decides whether a file genuinely MISSING a column is tolerated or
raises "schema mismatch in glob". A build that adds a column must rewrite
or expire older batches, which compaction already does.

quantile_cont in the service rollup is the smaller one. Holistic
aggregates retain every value of every group in a vector on the raw
allocator, so a pass that scans an hour of ingest allocates in proportion
to rows read. Measured on 30M rows at a 100MB limit: 861-912 MiB, with
duckdb_memory() reporting 0.0 MiB. approx_quantile keeps a fixed-size
t-digest per group instead: 8-9 MiB for the same query. The same holds
for PERCENTILE_CONT in the anomaly detector, which runs every 60s.

Percentiles are now approximate. The error is a fraction of a percent on
a figure describing a one-minute bucket, which nobody reads to three
significant digits.

Measured on the live demo host under continuous OTLP ingest, 12 GiB VM:

  before   peak 9.64 GiB, avg 5.87, climbing   (180 samples, 3h)
  after    peak 4.49 GiB, avg 4.14, flat       (38 samples)

During the "after" window the live batch count grew from 1,744 to 6,263
-- up 259% -- and RSS did not move. That decoupling was the stated test
before deploying, and it is the evidence that the ceiling is now the
configured memory_limit rather than the file count.

Adds TestServiceRollupLatencyDoesNotAllocatePerRow as a permanent gate,
verified in both directions: 912 MiB fail on the old expression, 9 MiB
pass on the new. Adds a per-tag fanout_duckdb_memory_bytes gauge so the
gap between what the process holds and what DuckDB admits to holding is
observable rather than inferred -- the absence of that series is why this
took as long as it did to find.
Removing union_by_name was wrong and review caught it. Verified on DuckDB
1.5.5: a glob without the flag hard-errors when the first file has a
column a later one lacks -- "schema mismatch in glob" -- and tolerates it
with the flag, reading NULL.

That is exactly the case the flag exists for. ensureSchemaBatch writes an
empty _schema.batch from the current binary's structs so the glob always
carries the full column set, and CreateViews names every column explicitly
and binds eagerly. So a build that adds a column would have failed at
NewDuck and refused to start until every pre-deploy batch aged out. The
claimed mitigation was also wrong: selectCompactionBatches only merges two
or more batches sharing a day and generation, so old-schema files can
survive to retention.

That trades an out-of-memory kill for a process that will not boot, which
is worse. The flag stays; its cost is bounded by keeping the live batch
count down, which is compaction's job.

The memory gauge was dead code: a push-style GaugeVec with no caller, so
the metric family exported nothing -- indistinguishable from "DuckDB holds
nothing", the most misleading possible reading for this series. It is now
published by a pull-based collector, resets before republishing so a tag
that stops appearing stops being reported, checks rows.Err rather than
publishing a partial sum as truth, and is released on Close so a closure
over a closed pool cannot freeze the gauges at stale values.

Also moves the detector's comment onto the query it describes, and reads
RSS from /proc/self/statm where available so the gate does not depend on a
ps that supports -o and -p.

Verified and not changed: DuckDB dedupes the two identical approx_quantile
expressions -- EXPLAIN shows two, not four.
@vishr vishr changed the title fix(query)!: stop binding every Parquet file into every query fix(query): bound the rollup's untracked allocation, and instrument the rest Sep 21, 2026
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.

1 participant