Global RSS limiter still suspends WAL tables when a reader query is over the cap
To reproduce
Note: this bug was discovered and documented by an AI agent that was tasked with diagnosing a memory leak leading to a crash in 9.4.0.
Version: master (checked 9848be5f). Same handleWalApplyFailure path as 9.4.0.
Component: WAL apply (ApplyWal2TableJob) + Unsafe.checkAllocLimit
Related: #7184 added opt-in per-query / per-mat-view / per-WAL-apply limits (cairo.query.memory.limit.bytes et al., default 0). #4745 added memory-pressure backoff, then still suspends if backoff is exhausted.
Description
Unsafe.checkAllocLimit is a process-global cap on tagged native mallocs (ram.usage.limit.percent, default 90% of host RAM). The log line says “RSS”; the counter is not kernel RSS (/proc VmRSS). Reader JIT/SAMPLE BY offload, group-by maps, and WAL apply writer scratch all increment the same RSS_MEM_USED.
When a reader query has already charged that counter up to the cap, WAL apply can fail a tiny NATIVE_TABLE_WRITER alloc (we observed 288 bytes, memoryTag=57) and take this path:
// ApplyWal2TableJob.handleWalApplyFailure
if (cairoException.isOutOfMemory()) {
txnTracker.getMemPressureControl().onOutOfMemory();
if (txnTracker.getMemPressureControl().isReadyToProcess()) {
engine.notifyWalTxnRepublisher(tableToken);
return; // retry
} else {
LOG.info().$("high memory pressure, table is backed off ...");
}
errorTag = OUT_OF_MEMORY;
}
// ...
engine.getTableSequencerAPI().suspendTable(tableToken, errorTag, errorMessage);Ingest keeps appending to WAL. Queries on existing data still work. Live data freezes until a human runs ALTER TABLE ... RESUME WAL. /status and /ping stay healthy. Swarm replica checks stay green.
#7184 is the right split (query vs mat-view refresh vs WAL apply), but:
- all three default to 0 (unlimited);
- Rosti keyed GROUP BY and some other allocators still only hit the global counter (called out in the 10.0.0 notes);
- so a default 10.0.0 install still has the 9.4.0 failure mode: one parallel aggregation can suspend a WAL table.
To reproduce
64 GiB-class host (or a small
ram.usage.limit.bytes), QuestDB with defaultram.usage.limit.percent=90, do not setcairo.query.memory.limit.bytes.WAL table with continuous ILP/HTTP ingest.
Run a parallel
SAMPLE BY/ JITGROUP BYlarge enough to tripglobal RSS memory limit exceeded(or several concurrent ones).Watch apply:
SELECT name, suspended, errorTag, errorMessage, memoryPressure FROM wal_tables();
Expected: the heavy query fails; ingest/apply keeps going (maybe slower / memoryPressure > 0).
Actual (9.4.0, and still the code path on master when the global cap is hit): suspended=true, errorTag=OUT OF MEMORY, last applied timestamp frozen, sequencer txn climbing.
Seen in production
QuestDB 9.4.0, 62 GiB host, no cgroup memory limit, no swap.
NATIVE_OFFLOAD= 59,634,922,544 (held after failedSAMPLE BY; factory leak now fixed by #7267, ring leak still open — issue #7590 ).- Kernel RSS ≈ 12 GiB,
MemAvailable≈ 43 GiB. ApplyWal2TableJob high memory pressure, table is backed offthenjob failed, table suspended [table=trades~22]withsize=288, memoryTag=57.- ~2h of unapplied WAL (~7M rows / ~530k txns) until
ALTER TABLE trades RESUME WAL. Tiny ILP applies then succeeded underparallelMemoryLimit=1;NATIVE_OFFLOADdid not drop. - Replaying the same
SAMPLE BYafter resume: HTTP 400 RSS-limit in tens of ms; table did not re-suspend (queries died before overlapping apply). Original suspend needed apply’s own alloc to fail while reduce workers were still running.
Suggested fix
Prefer all three; (1) is the production-safety default.
1. Do not suspend on global RSS OOM
In handleWalApplyFailure, if cairoException.isOutOfMemory() and the message is the global limiter (global RSS memory limit exceeded), never suspendTable. Keep the existing backoff / notifyWalTxnRepublisher loop. Log at error. Optionally expose memoryPressure=2 until RSS_MEM_USED falls.
Suspend should stay for corruption, disk full, and unrecoverable apply errors — not for “a SELECT is using the shared native counter.”
If you want a last-resort suspend, gate it on a dedicated knob (default off), not on exhausted backoff.
2. Default a query cap so one SELECT cannot fill the global budget
cairo.query.memory.limit.bytes default 0 means the new tracker is unused in the field. A conservative default such as:
min(ram.usage.limit.bytes, hostRam * ram.usage.limit.percent / 100) * 0.5
(or a documented fraction) would fail the query at its own tracker before WAL apply sees checkAllocLimit fail. Keep 0 as explicit “unlimited.” Reloadable is already there.
Charge Rosti / remaining global-only allocators to the query tracker, or the default cap will have holes (as 10.0.0 notes).
3. Naming / metrics
Rename or document that RSS_MEM_USED / global RSS memory limit exceeded is tagged native malloc, not VmRSS. memory_metrics() already exposes both RSS and TOTAL_USED; the exception should too (kernelRss= vs taggedNative=). Operators looking at docker stats will keep missing this.
QuestDB version:
master
OS, in case of Docker specify Docker and the Host OS:
Docker, Debian
File System, in case of Docker specify Host File System:
btrfs
Full Name:
Mark Dierolf
Affiliation:
FinancialContent
Have you followed Linux, MacOs kernel configuration steps to increase Maximum open files and Maximum virtual memory areas limit?
- Yes, I have
Additional context
No response
Source: questdb/questdb