#7590·questdb

`PageFrameReduceTask.of()` keeps peak-sized `NATIVE_OFFLOAD` buffers on the process-lifetime reduce ring

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

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.

To reproduce

Version: master (checked 9848be5f, 2026-09-02). Present in 9.4.0 through 10.0.0.
Component: SQL / async page-frame reduce (io.questdb.cairo.sql.async)
Related: #7267 fixed the same high-water leak on AsyncFilterContext (cached factories). This is the copy of that leak on the shared task ring.

Description

MessageBusImpl allocates a RingQueue<PageFrameReduceTask> per reduce shard at server start. Each task owns three DirectLongLists tagged NATIVE_OFFLOAD (filteredRows, dataAddresses, auxAddresses). Those objects live until process exit.

JIT filter reduction grows filteredRows to the full page frame:

java
// PageFrameReduceTask.populateJitData
if (filteredRows.getCapacity() < rowCount) {
    filteredRows.setCapacity(rowCount);
}

Default cairo.sql.page.frame.max.rows is 1_000_000 → 8 MiB per list.

PageFrameReduceTask.clear() already shrinks them (resetCapacity()). of() does not. The ordered reduce job never calls clear() when a slot is returned to the queue (PageFrameReduceJob.consumeQueue finally only does subSeq.done(cursor)). The next query rebinds the same slot via of(), which only filteredRows.clear()s (resets pos, keeps capacity).

So after any JIT-filtered parallel scan has seen a max-size frame, every reused ring slot pins 8 MiB of NATIVE_OFFLOAD until restart. Defaults: min(queryWorkers, 4) shards × min(4*queryWorkers, 256) slots. On a 32-worker host that is 4 × 128 × 8 MiB ≈ 4 GiB sitting on the global RSS counter with no query in flight.

That counter is what Unsafe.checkAllocLimit uses (tagged native malloc, not kernel RSS). A later SAMPLE BY / WAL apply can then fail with global RSS memory limit exceeded even though Linux RSS is fine.

To reproduce

  1. Start QuestDB with default ram.usage.limit.percent=90, JIT on, enough workers that the reduce ring is non-trivial (e.g. 8+).

  2. SELECT memory_tag, bytes FROM memory_metrics() WHERE memory_tag = 'NATIVE_OFFLOAD' → small.

  3. Run a JIT-eligible filter that matches most rows over a table larger than one page frame, e.g.

    sql
    SELECT count() FROM tab WHERE v > 0

    with tab having ≥ 2e6 rows.

  4. Query completes. Repeat the memory_metrics() query.

Expected: NATIVE_OFFLOAD returns near the post-compile baseline (initial list capacity 256 longs per list).
Actual: NATIVE_OFFLOAD stays ~8 MiB × (slots that participated), until process restart.

#7267’s AsyncFilterContextTest.testClearShrinksGrownRowIdLists covers the factory path only. There is no equivalent for the ring.

Suggested fix

Shrink when the slot is rebound to a different query, not between frames of the same query (hot path):

java
public void of(PageFrameSequence<?> frameSequence, int frameIndex, boolean countOnly) {
    final boolean sameQueryExecution = frameSequenceId == frameSequence.getId();
    // ... existing field copies ...
    if (!sameQueryExecution) {
        filteredRows.resetCapacity();
        dataAddresses.resetCapacity();
        auxAddresses.resetCapacity();
    } else {
        filteredRows.clear();
    }
    // ...
}

DirectLongList.resetCapacity() already credits Unsafe.recordMemAlloc negatively; checkAllocLimit returns immediately for size <= 0.

Optional extra: in PageFrameReduceJob.consumeQueue finally, after the collector is done with the task, call task.clear(). of() on the next bind is the smaller, safer change.

Add a test next to AsyncFilterContextTest: grow via populateJitData / setCapacity on a PageFrameReduceTask, call of() with a new frameSequenceId, assert filteredRows.getCapacity() is back to getPageFrameReduceRowIdListCapacity() and NATIVE_OFFLOAD dropped.

Seen in production

QuestDB 9.4.0, 62 GiB host, ram.usage.limit.percent=90. After parallel SAMPLE BY on a WAL table, NATIVE_OFFLOAD stayed at 59,634,922,544 bytes for hours (kernel RSS ~12 GiB). The factory-side share of that is #7267; this ring is the remainder that 10.0.0 / master still keep.

QuestDB version:

9.4.0, 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