Skip to content

Row groups above the 32 MB fetch-cache cap have a 100 percent miss rate: a full group re-read per fetched row #433

Description

@ChronicallyJD

From an external validation report (2026-08-05, two EC2 architectures, PGDG 18.4). Their
headline finding, which I reproduced independently and which the code confirms with a
different mechanism than they proposed.

The symptom

Fetching rows through an index on a blob-heavy columnar table costs a flat per-row price
equal to a whole row-group decode
, so total time is linear in rows fetched at a very
large constant. My reproduction, 500,000 rows, 9 columns, 1 KiB incompressible payload,
511 MB on disk, index path forced, plan asserted to be an Index Scan before each timing:

rows fetched plan total per row
1 Index Scan 346.0 ms 346.0 ms
10 Index Scan 3,138.7 ms 313.9 ms
100 Index Scan 31,541.9 ms 315.4 ms
1,000 Index Scan >300 s, cancelled -

The fetches are in perfect key order, which is the friendliest possible access pattern.
Heap on the same data does a point lookup in 1.39 ms; we take 245 ms, 176x slower.

The reporter measured the same shape at a different scale: ~25 ms/row, 100k rows killed
after 33 minutes.

The mechanism, and it is not what the report said

The report concluded there is "no caching across consecutive fetches". There is a fetch
cache: static PgColumnarFetchGroup columnarFetchCache[4] (src/columnar_reader.c:1932),
keyed on (storageId, groupNumber) plus CommandId (:2078-2135). It works, and below
the cap a control table shows a flat 16-18 ms and about 6 extra buffers per row.

The real trigger is a whole-entry reset at the end of every fetch when the group's raw
stored size exceeds the cap:

/* src/columnar_reader.c:2583-2584 */
if (rg->byteLength > COLUMNAR_FETCH_CACHE_MAX_BYTES)
    pgcolumnar_fetch_entry_reset(entry);

COLUMNAR_FETCH_CACHE_MAX_BYTES is 32 MB (src/columnar.h:192). Above it the hit rate is
not merely low, it is zero by construction: every fetch populates the entry, uses it
once, and discards it. Measured on a 60,909,712-byte group in perfect key order:
+7,476 shared_hit per row, which is the whole 60 MB group re-read per fetched row.

That is a better bug than the one reported, because it is a cliff rather than a slope, and
it has a threshold to move.

Two details the report did not have:

  • The test at :2583 uses the group's raw stored bytes, so a narrow projection gets no
    protection. Selecting one small column from a wide table still trips it.
  • The DML path is worse. columnar_tableam.c:1186 reaches columnar_reader.c:2600 with
    allColumns = true, about 63 ms per row version fetched, which is what the reporter saw
    as UPDATE ... WHERE id % 10 = 1 running at ~5.7 ms/row on their smaller chunks.

Why no test caught it

test/native_fetch_cache.sh builds (id int, v int, t text) with repeat('a',150). That
compresses far below 32 MB, so the :2583 branch never executes anywhere in the suite.
The regime with a 100 percent miss rate is untested, which is why it survived #143, #157
and #359.

Any fix should come with a fixture whose row groups genuinely exceed the cap, and the
fixture must assert its own on-disk size. Mine did not on the first two attempts: the
payload compressed 17x and then 25x, because repeat(md5(...), 32) repeats one string and
an uncorrelated scalar subquery is evaluated once for all rows. Both would have produced a
green test that measured nothing.

Not the same as #359

#359's per-column overflow release (:2542-2550) is real and separate. This table falls
into the whole-entry carve-out that #359 deliberately left alone.

Severity

High. It is silent, it is on the ordinary index-scan path, and the shape that triggers it
(wide rows, an index, point lookups) is the shape a user reaches for when they add an index
to a columnar table. Combined with the planner issue filed alongside this, the database
chooses this path on its own.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions