Conversation
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.
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.
Partial progress on #254. This no longer claims to fix the OOM — see below.
What this contains
quantile_cont→approx_quantile(verified, real, insufficient)Holistic aggregates retain every value of every group in a vector on the raw allocator, outside anything
memory_limitbounds.30M rows at
memory_limit='100MB': 861–912 MiB, withduckdb_memory()reporting 0.0 MiB.approx_quantilekeeps a fixed-size t-digest per group: 8–9 MiB. Same substitution forPERCENTILE_CONTin 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.
TestServiceRollupLatencyDoesNotAllocatePerRowgates it, verified in both directions: 912 MiB fail on the old expression, 9 MiB pass on the new.A per-tag
fanout_duckdb_memory_bytesgaugeSo
process_resident_memory_bytes − Σ fanout_duckdb_memory_bytes − go_memstats_heap_sys_bytesis 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=truefrom 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:
ensureSchemaBatchwrites an empty_schema.batchfrom the current binary's structs precisely so the glob carries the full column set, andCreateViewsnames every column explicitly and binds eagerly. So a build that adds a column would fail atNewDuckand the process would refuse to start until every pre-deploy batch aged out.My claimed mitigation was also wrong:
selectCompactionBatchesonly 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_limitplus a fixed margin. That's a separate change: raise compaction throughput, or add ingest backpressure.Review findings addressed
RefreshDuckDBMemoryhad zero callers — the metric family exported nothing, indistinguishable from "DuckDB holds nothing". Now a pull-based collector.releaseMemoryGaugeswas assigned but never invoked;Closenow 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.SetDuckDBPoolSource's./proc/self/statmwhere available, so the gate doesn't depend on apssupporting-o/-p; long scan skipped under-short.Verified and not changed: DuckDB dedupes the two identical
approx_quantileexpressions —EXPLAINshows two, not four.just checkpasses.