#7592·questdb

Global RSS limiter still suspends WAL tables when a reader query is over the cap

Author: mdierolfCreated Sep 2, 2026Updated Sep 2, 2026

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:

java
// 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

  1. 64 GiB-class host (or a small ram.usage.limit.bytes), QuestDB with default ram.usage.limit.percent=90, do not set cairo.query.memory.limit.bytes.

  2. WAL table with continuous ILP/HTTP ingest.

  3. Run a parallel SAMPLE BY / JIT GROUP BY large enough to trip global RSS memory limit exceeded (or several concurrent ones).

  4. Watch apply:

    sql
    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 failed SAMPLE 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 off then job failed, table suspended [table=trades~22] with size=288, memoryTag=57.
  • ~2h of unapplied WAL (~7M rows / ~530k txns) until ALTER TABLE trades RESUME WAL. Tiny ILP applies then succeeded under parallelMemoryLimit=1; NATIVE_OFFLOAD did not drop.
  • Replaying the same SAMPLE BY after 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