Repository navigation
server: RSS exceeds --memory-budget-bytes under concurrent Parquet SQL and is OOM-killed #2633
Description
Found in the #2055 M1 run (ADR-2040 D7, ClickBench Parquet lane), main at 01d9760, on a dedicated c6a.4xlarge (16 vCPU, 30 GB RAM). Nothing else ran on the box except RustFS on loopback.
What happens. Under the D7 concurrency phase (10 connections cycling the 43 ClickBench statements over a Parquet table), ravel-server grows past its own memory budget until the kernel OOM-kills it. Reproduced three times out of three, on both table layouts:
| Run | Killed at | Anon RSS at the kill |
|---|---|---|
| arm A (100 files), first | t ≈ 210 s | 30.0 GB |
| arm A (100 files), diagnostic rerun | t ≈ 220 s | 29.96 GB |
| arm B (single file) | t ≈ 300 s | 28 GB sampled peak |
The budget was pinned with --memory-budget-bytes 27000000000. The server's startup stamps:
| Setting | Value |
|---|---|
memory_overhead_reserve_bytes |
2,147,483,648 |
memory_hard_caps_bytes |
12,150,000,000 (cache_max_bytes 10.8 GB + catalog_cache_max_bytes 1.35 GB) |
memory_remainder_bytes |
14,850,000,000 |
sql_max_query_bytes |
13,365,000,000 |
sql_tenant_max_bytes |
13,365,000,000 |
The same statements run one at a time complete on both layouts with no error: arm A in 100.3 s cold and 35.1 s hot, arm B in 102.8 s cold and 39.0 s hot.
Attribution. The diagnostic rerun scraped /metrics every 9 s. At t=184 s, the last snapshot before the kill:
| Gauge | Value |
|---|---|
ravel_process_allocator_bytes{allocator="jemalloc",stat="resident"} |
30.05 GB |
ravel_memory_reserved_bytes{component="sql"} |
12.41 GB (cap 13.37 GB) |
ravel_cache_resident_bytes{cache="fetch"} |
10.58 GB (cap 10.8 GB) |
ravel_memory_reserved_bytes{component="fetch"} |
0.15 GB |
ravel_cache_resident_bytes{cache="catalog"} |
0 |
Every accounted component held its cap. One statement was refused cleanly at the SQL pool's limit (query memory budget exhausted: tenant memory budget exhausted). But the accounted total is 23.14 GB against 30.05 GB jemalloc-resident: about 6.9 GB that no budget sees. That untracked share moves with concurrent SQL work: about 4 GB at t=128 s, about 8.5 GB at t=147 s, about 6.9 GB at t=184 s. The budget is what the process is meant to stay under, so the process exceeds it by 3 GB or more, and on this box that is the difference between running and being killed.
Candidates, not yet checked:
- memory a query allocates and never charges to its SQL reservation: Parquet pages decoded in the pinned reader before batches are reserved, the HTTP response's JSON encoding buffers, per-connection result buffering;
- jemalloc keeping dirty pages resident after they are freed. The exporter reports only
stat="resident";allocatedandactivewould separate live heap from retention.
To do:
- Add jemalloc
allocatedandactivetoravel_process_allocator_bytes, so live heap and retention are told apart under load. - Take a heap profile under the same 10-connection load (the
heap-profilingfeature) and name the unaccounted allocation sites. - Charge them to the SQL reservation, or bound them, so the process stays under
--memory-budget-bytesunder concurrency. - A test, or a bench assertion, that RSS under the concurrency phase stays at or below the budget.
The raw samples (mem.tsv, gauges.txt) and the four reports are attached to the #2055 results comment.
Activity
pmoust commentedon Oct 7, 2026
Correction to to-do item 1 above:
ravel_process_allocator_bytesalready exportsstat="allocated"andstat="active"besidestat="resident", throughrender_allocator_familyinservices/ravel-server/src/metrics.rs. My diagnostic sampler kept only lines matchingresident, so it dropped them; the data was there and I did not record it.So item 1 is a measurement step, not a code change: the next run under the same 10-connection load records all three series.
allocatedfar belowresidentpoints at allocator retention;allocatedclose toresidentpoints at live heap that the budget does not charge. Items 2 to 4 (heap profile, charge or bound what it names, assert RSS against the budget) stand.pmoust commentedon Oct 7, 2026
Pre-registration: scaled reproduction on a fleet executor
Where it runs. On an amd64 fleet executor (16 vCPU, 30 GB RAM), not a ClickBench box. The question is a memory-attribution one, so the executor's timing noise does not matter.
Setup.
ravel-serveris built--release --features sql,heap-profilingfrom main at2f7728f43or later.--memory-budget-bytes 8000000000(8 GB).hitsParquet table: the first 20 of the 100 partitioned files (arm A layout), about 2.8 GB.clickbench_parquet_bench. Since bench: the concurrency phase spins on an unreachable engine instead of recording that it died #2632 the phase ends at the first connection-level failure, so an OOM shows up with a timestamp.Sampling. Every 5 s, from
/metrics:ravel_process_allocator_bytesseries:allocated,active,resident;ravel_memory_reserved_bytesfor every component;ravel_cache_resident_bytesfor every cache;/proc/<pid>/status."Accounted" means the SQL and fetch reservations plus the fetch and catalog cache residency, the same sum as M1's 23.14 GB.
Heap profile.
_RJEM_MALLOC_CONF=prof:true,lg_prof_sample:19,lg_prof_interval:31, plus oneprof.dumpat the sample where resident minus accounted is largest. Summarised withjeprof --texton the dump.Expected figures and bands.
residentminus accounted is at least 1.2 GB (15% of the budget) during the concurrency phase. If it stays under 0.4 GB (5%) for the whole phase, the reproduction failed at this scale. That result is reported as a miss, and the next step is a full-size box, not a conclusion that the gap is absent.residentminusallocatedis at least half the gap.allocatedminus accounted is at least half the gap.Shape check. The 43 statements run once, serially, before the phase starts. Each statement must either answer or be refused with a typed memory-budget error before the phase result counts. Any other error makes the run invalid, because the smaller budget can change which statements hit the pool limit compared with M1's five.
wip: issue 2633 jeprof summaries, site attribution and final samples
pmoust commentedon Oct 7, 2026
Result of the scaled reproduction (against the pre-registration above)
Fleet task 6cd1ef60 ran this on an amd64 executor (16 vCPU, 32 GB). Main was at
2f7728f43. Store: RustFS 1.0.0 on loopback. Budget 8 GB; 20hitsfiles, 2.71 GB; 360 s at 10 connections.The full write-up, samples, heap dumps and attribution scripts are on branch
task/6cd1ef60-6bf8-4058-830c-884fcdd4f760/result, underinvestigations/2633/. They are not merged to main.Validity: valid. All 43 serial statements answered. The concurrency phase completed 2,626 queries. It had 46 errors, all typed 422 memory-budget refusals. The server was not OOM-killed; peak VmRSS was 9.01 GB on a 32 GB host.
BytesMutread buffers on the S3 GET path.What the series shows beyond the bands:
resident − activestayed resident 457 s after the last query. That fits jemalloc purging dirty pages only on allocator activity, with no background thread. This is observed, not tested.ArrowBytesViewMapand parquetdecode_pageunderravel_parquet::boundary::BoundaryStream::poll_next.value.len()(crates/ravel-cache/src/cache.rs:372) while pinning a larger hyper allocation.ravel_memory_handoff_overlap_bytes, which was 1.25 GB at +157 s. The live-heap figure there is therefore understated by up to that amount. A deviation, as noted.Deviations, all listed in the README:
Fix plan
Two causes act independently, so two tasks run in parallel. They are in different crates.
ravel-server. Set a bounded dirty and muzzy decay, compiled in, so idle and between-query retention is returned without anyone setting_RJEM_MALLOC_CONF. Acceptance: in a test or startup stamp, the effectiveopt.background_threadand decay values read back frommallctland appear in the startup stamps.Bytesslice of a larger buffer, and asserts both the charge and that the larger buffer is released.After both land, the same scaled run is repeated, pre-registered again. Expected bands for that run:
allocated − cachefloor gone.The DataFusion query-state share (1.27 GB at 3.96 GB per query cap) gets a third task. Its form depends on what is left after 1 and 2: either a headroom on the SQL pool's share of the remainder, or charging what DataFusion under-reports. It is decided on the rerun's numbers, not now.
pmoust commentedon Oct 8, 2026
Pre-registration: rerun after #2652 and #2653
#2652 (jemalloc background purge thread) and #2653 (cache stores an exact-size copy) are merged. This rerun repeats the scaled reproduction exactly, on main at the merge of both:
hitsfiles;The only intended variable is the two fixes. The first run is the baseline: task 6cd1ef60, branch
task/6cd1ef60-6bf8-4058-830c-884fcdd4f760/result.Same method:
Also recorded:
allocator_background_threadstartup stamp, and theravel_process_allocator_background_threadgauge in every sample;ravel_memory_handoff_overlap_bytesbeside each sample, so the double count noted last time is visible.Expected figures and bands
trueand gauge1in every sampleresident−allocated)allocatedminus cache-resident, 300 s or more after the last queryresident− accounted) over the phaseFor row 4, between 0.15 GB and 0.3 GB is reported as partial.
Row 5 has no band because it still contains the DataFusion query-state share (1.27 GB at the baseline peak), which neither fix targets. The third task, for that share, is sized from what this run leaves.
pmoust commentedon Oct 8, 2026
Rerun result, against the pre-registration above
Fleet task db9e7a3c measured on the baseline host (
ravel-clickbench-ubuntu-2) at main5378afd2a, which includes #2652 and #2653.Afterwards, the
jeprof/addr2lineattribution step pushed the executor's cgroup to its 28.8 GB limit, and the executor was OOM-killed before the task could commit. The fleet then requeued the task to a 15 GB host, where the memory tripwire stopped it.The measured data was intact on the first host, so I copied it off by hand. It is on branch
task/db9e7a3c-73f0-4fd9-ac9b-8bcb2327aa8d/result, underinvestigations/2633-rerun/, with a README. Nothing was re-measured. The executor service on that host is running again.true, gauge 1 in 186/186 rowsallocated− fetch cacheIdle is fixed.
BytesMutread buffers are gone, so the 0.4 GB was cache-pinned slices, and fix(cache): store an owned copy when an entry pins a larger buffer #2653 removed it.Under load it is not fixed.
residentminusactive: freed pages jemalloc has not returned yet.The background thread is running. So this is decay timing, not purging that never happens: jemalloc returns freed pages over
dirty_decay_ms(10 s by default), and 10 concurrent aggregations free several GB inside one window. That mechanism is inferred, not tested.One run each cannot separate the fixes from run-to-run variation in which heavy statements overlap. The baseline's peak came at +357 s; this one came at +85 s. Row 2 says #2652 alone does not bound retention under load. It does not say #2652 made it worse.
Next: a decay experiment, pre-registered here
One fleet task on the same 30 GB host, with one binary built from current main (
--features sql, no heap profiling, nojeprof). The same scaled workload runs four times, in the order A1, B1, A2, B2:_RJEM_MALLOC_CONFunset._RJEM_MALLOC_CONF=dirty_decay_ms:1000,muzzy_decay_ms:0. The background thread is still enabled by the server, and the stamp must showsource="server".If B passes, the server sets these decay values itself at startup and stamps them, the same way #2652 handles the background thread. The
opt.*values are read-only through the safe API, so the change is either compiled into the build's jemalloc configuration or made through a writablearenas.*control. Which one is decided after the experiment.The DataFusion query-state share stays the separate third task. At the max-uncharged sample,
allocatedminus accounted is 1.87 GB. Up to 1.27 GB of that is handoff-overlap double counting, so the third task is sized against a smaller real figure than the baseline suggested.wip: #2633 decay arm B1
wip: #2633 decay arm A2
wip: #2633 decay arm B2
docs(investigations): measure jemalloc decay against #2633 retention
pmoust commentedon Oct 8, 2026
Decay experiment result, scored against the pre-registration above
Fleet task c3d2ad79 ran on the same 30 GB host. It used one server binary built at
5ad3f6awith--features sql, and ran four arms in the order A1, B1, A2, B2:_RJEM_MALLOC_CONF=dirty_decay_ms:1000,muzzy_decay_ms:0.All four arms are valid. Each answered 43 of 43 serial statements, and every startup stamp read
allocator_background_thread=true source="server". The write-up and data are on branchtask/c3d2ad79-81ac-4554-8d4f-28a3be9c9ff0/result, underinvestigations/2633-decay/.A 1 s decay works on average and leaves the peak in place:
B's retention is spiky. In B1, five consecutive samples read 0.9, 2.3, 4.3, 2.1 and 0.9 GB. When several heavy aggregations free their memory inside one window, 4 to 5 GB is still resident at that moment, and the peak is what an OOM sees. The throughput cost cannot be separated from noise: the two A runs alone differ by 9.2 % in qps.
So decay tuning is not the bound #2633 needs. The budget accounts reservations, and the process's real footprint can run 4 to 6 GB past them for seconds at a time. Within those seconds, memory freed but not yet returned dominates. Under the default decay, the uncharged live heap is small at those moments (0.16 to 0.96 GB).
The fix shape is a decision for the owner, asked separately. The options are:
residentfigure;One more finding, outside #2633's scope: a fetch-memory refusal reaches the client as HTTP 503 "upstream storage temporarily unavailable" (1 to 3 per run). It is a budget refusal and should arrive as a typed 422 like the others.
docs(adr-2633): restate the uncharged-heap basis and fold catalog 503s
pmoust commentedon Oct 8, 2026
Pre-registration: where DataFusion's reservation falls short of its allocations
The owner's decision is to charge DataFusion's uncharged state before setting the gate's mark. This measurement finds what is uncharged. It runs one statement at a time, with no concurrency, so the process-wide
allocatedfigure belongs to that statement alone.Setup
hitstable.ravel-serverbuilt--features sqlat current main, plus a temporary instrumentation patch that is never merged. The SQL pool (TenantDelegatingPool) records each consumer's peak reservation, keyed by DataFusion'sMemoryConsumername, and dumps the table at the end of each statement.stats.allocatedevery 20 ms during each statement. Its peak above the pre-statement value is the statement's allocation peak.Expected figures and bands
Report
size()such as hash-table capacity versus length, output batches held by downstream operators, repartition and coalesce buffers, or Parquet decode buffers outside any reservation.The heap profile is taken only for the single worst statement. Its
jeprofstep runs with its parallelism capped, because the last uncappedaddr2linefan-out OOM-killed the executor.wip: #2633 charging matrix runs
wip: #2633 profile attribution and analysis scripts
9 remaining items
wip: #2633 hold run R2
pmoust commentedon Oct 10, 2026
Result: concurrency rerun with the aggregate hold (PR #2685), scored against the pre-registration above
Fleet task 4f2f129b ran two runs, R1 and R2, on the 30 GB host at main
6b794db90, with the hold in place. Both runs are valid (43/43 serial statements answered). Write-up and data: branchtask/4f2f129b-2a5f-4560-8e5b-70a9034a53f8/result,investigations/2633-hold/.allocated − accounted + overlapWhat this establishes:
handoff_overlapequalsfetch_reserved. So row 1 is in practiceallocated − sql_reserved − cache resident.resident − allocatedstill peaks at 6.0 to 6.6 GB. That is exactly what ADR-2633's resident gate and forced purge are for.Next: revise ADR-2633 (PR #2661) with G taken from this run.
8.00 + 0.54 − 0.06 − 2.48 − 0.27 = 5.73 GB = 0.716 B, so a 70 % default.docs(adr-2633): re-derive the gate mark after the aggregate hold
docs(adr-2633): pre-register throughput and split sampler CPU
fix(sql): drop the aggregate hold now DataFusion 55 reserves emitted …
fix(sql): drop the aggregate hold now DataFusion 55 reserves emitted …
pmoust commentedon Oct 10, 2026
Finding: the resident gate closes for the whole concurrency phase at a 27 GB budget on a 32 GB host
Seen on the fresh c6a.4xlarge (
ravel-df55-ab, 16 vCPU, 32 GB) running the #2720 parquet A/B, arm A = main 154c947 (ADR-2633 tasks 1 and 2 included, DataFusion 54.1), stock flags plus--memory-budget-bytes 27000000000. Not a pre-registered run of the ADR's acceptance test; it is the first real workload the gate has seen.Stamp:
memory gate resolved enabled=true high_water_bytes=18900000000 high_water_percent=70 memory_budget_bytes=27000000000 interval_ms=100 wait_ms=2000 source="default". Resolvedcache_max_bytes10,800,000,000 (loopback 40 %),memory_remainder_bytes14,850,000,000,sql_max_query_bytes=sql_tenant_max_bytes13,365,000,000.segment fetch ... upstream storage temporarily unavailable(fetch-memory refusals, the 503 class task 4 moves to 422), 140query memory pool is full and spill is disabled, 167process memory budget exhausted.What this says about ADR-2633 section 2: the default derivation and "on the current code the gate is expected to purge in every run and to refuse in none" were argued for the 8 GB scaled run (budget = host). At a budget of 27 GB on a 32 GB host, 70 % of the budget (18.9 GB) sits below cache plus one SQL pool (24.2 GB), so a concurrent workload that fills the pool closes the gate and the purge cannot reopen it: the bytes are live, not retained. The mark has to leave room for the accounted ceilings (caps plus the SQL tenant pool), or the gate has to exempt accounted growth, or the startup check has to count the SQL pool as it counts the caps.
For the #2720 A/B the gate is a confound landed today on both arms, so the parquet and RLOG passes rerun with
--disable-memory-gateon both arms, stamped. Evidence kept on the box under/root/ab/gated/passes/p1A/(server-errors.txt,vmrss.tsv,bench-report.json) and will be attached to the task 6 ticket (#2734).pmoust commentedon Oct 10, 2026
Second data point from the same box, same arm A binary, same 27 GB budget, gate OFF (
--disable-memory-gate): the server was OOM-killed by the kernel 34.4 s into the concurrency phase (the bench reportsthe engine became unreachable 34.4 s into the phase; the shell reportsKilled). So at this host shape the gate traded an OOM kill for an 88 % refusal rate, which is the ADR's intended direction but not a usable operating point: the budget itself is too large for the host once the SQL pool, the caches and jemalloc retention are summed. The A/B continues at a 20 GB budget with the gate off on both arms; the gate-on run at 20 GB is a separate task 6 measurement.docs(adr-2633): amend the gate to close on host available memory
docs(adr-2633): amend the gate to close on host available memory
docs(adr-2633): amend the gate to close on host available memory
pmoust commentedon Oct 11, 2026
Task 2 follow-up #2806 merged as PR #2810 (main 1590f94): the gate now closes on host available memory below a floor (default max(512 MiB, 2 % of host) clamped to host/8; purge floor twice that clamped to host/4; purge at most once per 5 s; cgroup term is working-set headroom). The 2026-10-11 amendment in the ADR records the arithmetic, the RSS counter-case, the decision and the new acceptance for task 6 (#2734). Tasks 3 to 6 proceed against this gate; task 3 (#2731) still waits on #2774.
fix(sql): drop the aggregate hold now DataFusion 55 reserves emitted …
pmoust commentedon Oct 11, 2026
ADR-2633 task 4 landed: PR #2841, main 3c47dde. Fetch-memory and catalog-decode refusals answer 422 with the budget class on PromQL HTTP, SQL HTTP and Flight SQL; the DDL footer-read path stays at 503 under #2840. Task 3 (#2731) dispatches after #2774; task 5 (#2733, docs incl. the ADR-1170 amendment) can go now; task 6 (#2734) is the acceptance ravel-perf is running on #2842.
fix(sql): drop the aggregate hold now DataFusion 55 reserves emitted …