Skip to content

#361's fetch cache is not bounded by 4x the cap: one entry measures 62 MB against 32 MB on a wide varlena table, and gets 1.14x #364

Description

@ChronicallyJD

Raising this as an issue because #361 merged before my review landed, and a measured
defect should not live only in a closed PR thread. Numbers are unchanged from my
comment there; this adds nothing new except a place to track it.

Summary

#361 states, in the PR body and in design/ISSUE_359_FETCH_CACHE_PARTIAL.md, that
the per-column release keeps the fetch cache "bounded by 4 x the cap". It does not.
On a wide varlena table a single entry measures 62 MB against a 32 MB cap, and
nothing can bring it back down.

The same shape gets almost none of the speedup the change is for: 1.14x, against
the 13.6x #361 reports at its own crossing.

Measurement

Fixture: 150,000 rows x 60 text columns of 100 B plus a bigint key — one stripe at
the default stripe_row_limit, index on the key, forced index scan over 200 rows.
Every column varlena, so every one gets a valOffset. pg18n (-O2), one run at a
time on an idle box, main before #361 as the control.

Memory, from pg_backend_memory_contexts read inside the transaction so the
statement-scoped contexts are still alive:

columnar fetch column | n=2 | total=18 MB     <- only two columns stayed resident
columnar fetch group  | n=1 | total=44 MB     <- groupBuffer + the retained indexes
ALL columnar fetch contexts total: 62 MB

Timing, same fixture, same 200 fetches:

build time
before #361 145,132 ms
after #361 127,699 ms

Why

Two things #361 makes non-releasable, plus a whole-entry drop that can no longer see
them:

  1. rankPrefix[c] and valOffset[c] stay in entry->cx by design, so they
    outlive a released column. That part is right — without it Fetch by row number re-reads and re-decodes the whole row group, making UPDATE and DELETE quadratic within a group #143's quadratic
    returns through the overflow path, and the design doc argues it well.
  2. But valOffset is 4 bytes per value per varlena column. At the default
    stripe_row_limit of 150,000 that is ~600 KB of permanently-resident state per
    varlena column, and 60 of them is ~34 MB — over the cap on its own, before a
    single decoded byte is retained. That is the 44 MB above.
  3. The whole-entry drop now keys on raw bytes:
    if (rg->byteLength > COLUMNAR_FETCH_CACHE_MAX_BYTES). This text compresses well,
    so byteLength stays small and the drop never fires.

The design doc justifies retention with "valOffset is 4 bytes per value against a
value that is typically wider". That holds while the column is resident. Once it is
released the stream goes and the index stays, so the thing it is being compared
against is no longer there.

The 1.14x follows from the same fact: once the retained indexes alone exceed the cap,
MemoryContextMemAllocated(entry->cx, true) is over the limit on every decode, so
every newly decoded column is released immediately. Two columns got in before the
entry crossed; the other 58 overflow on every fetch. It degrades to roughly the
pre-#361 behaviour while now pinning 62 MB instead of dropping it.

Real ceiling is 4 x (cap + retained indexes + groupBuffer) — ~248 MB on this shape,
not 128 MB.

Fix direction

Cheapest first; I have measured none of them.

  1. Release valOffset[c] for a column that has overflowed, keep rankPrefix[c].
    rankPrefix is 4 bytes per 64 rows (~9 KB per column here) against ~600 KB for
    valOffset, so this keeps the constant-time row reach that matters for Fetch by row number re-reads and re-decodes the whole row group, making UPDATE and DELETE quadratic within a group #143 at
    ~1.5% of the memory. The cost is that an overflowed varlena column re-walks its
    value stream to rebuild offsets — a constant factor on a decode that is already
    happening per fetch, not a return of the quadratic. This is the one I would try.
  2. Count the retained indexes against the cap when deciding admission, so
    admission stops before retention eats the budget.
  3. A whole-entry drop keyed on the entry's actual footprint rather than on raw
    bytes, as a backstop for whatever (1) and (2) do.

Whatever lands, the "bounded by 4 x the cap" sentence in the body and the matching
line in the design doc need to change, and the test wants to assert the bound
(read pg_backend_memory_contexts in-transaction) rather than only the timing ratio —
a timing test cannot distinguish "proportional" from "degraded back to total but now
holding memory".

Not a regression

Worth saying plainly: before #361 this shape pinned nothing and re-decoded
everything, so the memory ceiling is new but the time is not — 145 s to 128 s is an
improvement, just a marginal one. #361 is a clear win on the shapes it targets. This
is the shape it does not reach.

Provenance

Found while reviewing #361 (comment on the merged PR has the same numbers). Fixture
and method are in that comment; happy to take the patch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions