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.
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:
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)plusCommandId(:2078-2135). It works, and belowthe 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:
COLUMNAR_FETCH_CACHE_MAX_BYTESis 32 MB (src/columnar.h:192). Above it the hit rate isnot 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:
:2583uses the group's raw stored bytes, so a narrow projection gets noprotection. Selecting one small column from a wide table still trips it.
columnar_tableam.c:1186reachescolumnar_reader.c:2600withallColumns = true, about 63 ms per row version fetched, which is what the reporter sawas
UPDATE ... WHERE id % 10 = 1running at ~5.7 ms/row on their smaller chunks.Why no test caught it
test/native_fetch_cache.shbuilds(id int, v int, t text)withrepeat('a',150). Thatcompresses far below 32 MB, so the
:2583branch 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 andan 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 fallsinto 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.