Skip to content

perf: skip column decoding for cached entities and precompute constructor metadata#252

Merged
zantvoort merged 1 commit into
mainfrom
perf/skip-decode-on-cache-hit
Jul 14, 2026
Merged

perf: skip column decoding for cached entities and precompute constructor metadata#252
zantvoort merged 1 commit into
mainfrom
perf/skip-decode-on-cache-hit

Conversation

@zantvoort

Copy link
Copy Markdown
Collaborator

Implements the remaining checklist item of #246: skip parent column decoding on cache hits. The mapper's early cache lookup already skips construction when a nested entity is cached, but every column of every row was still decoded out of the JDBC ResultSet before the mapper ran, including the repeated parent columns the lookup then ignored.

Column skipping

A new ColumnSkipper probes the same caches as the mapper's early lookup, but before column extraction. For each entity column region it decodes only the columns up to and including the primary key; on a cache hit the remaining columns are left undecoded, and the mapper resolves the entity from the primary key alone.

  • Skip regions (start, end, PK offset, entity type) are computed once per record type alongside the compiled argument plan, including nested regions (a city region inside an owner region). Outer hits skip inner regions wholesale; outer misses give inner regions their own probe.
  • Cache policy parity: nested regions consult the transaction-scoped EntityCache at REPEATABLE_READ+ and the query-scoped WeakInterner otherwise; the top-level region only consults the entity cache, since top-level records are never interned. This mirrors RecordStep and newInstance exactly.
  • Correctness guards: column access stays in ascending order (safe for drivers that only support forward column access within a row), and interner hits are pinned in a strong reference list until the next row so a garbage collection between probe and mapping cannot clear the weakly held entity.
  • ObjectMapper exposes the skipper via a default method returning null, so only the record mapper opts in; sealed-type mappers and the JPA path are untouched.

Constructor metadata

Record construction carried per-invocation reflective overhead on the row mapping hot path: getParameterTypes() and getParameters() clone their arrays on every call, setAccessible(true) ran per call, and the enum step resolved its mapper per value. Constructor metadata (parameter names, non-null flags, primitive flags, the accessible constructor instance) is now precomputed and cached per constructor; enum mappers are resolved once at plan compilation. Error messages and diagnostics are unchanged.

Testing

ColumnSkipperIntegrationTest counts positional column reads through a JDBC proxy and asserts the exact expected number for the pet → owner → city graph: full decode on first occurrence, key-only decode on repeats, full decode for null foreign keys, nested city regions, and interner identity across rows. Full suites green: storm-core 2264, storm-kotlin 1627, storm-java21 517.

Measured effect (#214 suite, PostgreSQL 17, same session)

Workload Before After
joinWithMapping10 577 µs 444 µs (−23%)
joinWithMapping100 1281 ± 372 µs 913 ± 69 µs (−29%)
joinWithMapping1000 3636 µs 3325 µs (−9%)
projection 975 µs 884 µs (−9%, constructor metadata)
singleRowById unchanged unchanged (no duplicate regions, probe cost not measurable)

The collapsed error bar on joinWithMapping100 (±372 → ±69) reflects the removed allocation pressure.

…ctor metadata

Queries that repeat the same entity across rows (joins over eager
foreign keys) paid for decoding the parent's columns on every row even
though the mapper's early cache lookup skips construction on a hit. The
new ColumnSkipper probes the same caches before column extraction: for
each entity region it decodes only the columns up to and including the
primary key and, on a hit, leaves the remaining columns undecoded. The
mapper then resolves the entity from the primary key alone. Column
access stays in ascending order, and interner hits are pinned in a
strong reference list until the next row so a garbage collection between
probe and mapping cannot clear them. Skip regions are computed once per
record type alongside the compiled argument plan.

Constructing a record also carried per-invocation reflective overhead:
getParameterTypes and getParameters clone their arrays on every call,
setAccessible ran per call, and the enum step resolved its mapper per
value. Constructor metadata (parameter names, non-null flags, primitive
flags, the accessible constructor) is now precomputed and cached per
constructor, and enum mappers are resolved once at plan compilation.

The integration test counts positional column reads through a JDBC
proxy and asserts the exact expected number for the pet-owner-city
graph, covering nested regions, interner identity, and null foreign
keys.
@zantvoort zantvoort merged commit af75d41 into main Jul 14, 2026
7 checks passed
@zantvoort zantvoort deleted the perf/skip-decode-on-cache-hit branch July 14, 2026 06:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant