Skip to content

perf: cache field parsers and prebuilt rows per query shape - #3753

Open
Iaotle wants to merge 1 commit into
brianc:masterfrom
Iaotle:perf/cache-field-metadata-per-query-shape
Open

perf: cache field parsers and prebuilt rows per query shape#3753
Iaotle wants to merge 1 commit into
brianc:masterfrom
Iaotle:perf/cache-field-metadata-per-query-shape

Conversation

@Iaotle

@Iaotle Iaotle commented Aug 18, 2026

Copy link
Copy Markdown

What

Result.addFields currently calls getTypeParser per column and rebuilds the prebuilt empty row object on every query. Real workloads repeat a small set of result-set shapes, so this work is almost always identical to what the previous query already computed. In a production Node service we profiled (~700–800 queries/s through pg + an ORM), addFields + getTypeParser self-time added up to ~9.6% of the main thread's on-CPU time — pure per-query overhead, since most queries return a single row.

This PR caches the parser array and prebuilt row per query shape:

  • Keyed on the types object in use (a WeakMap), then on a shape signature of name/dataTypeID/format per field (NUL-separated — Postgres identifiers cannot contain NUL).
  • Invalidated by a version counter that TypeOverrides.setTypeParser bumps, so client.setTypeParser(...) between queries applies to later queries exactly as before (unit-tested).
  • Only used for types objects whose registrations are versioned: TypeOverrides instances backed by the global pg-types module (the standard Client path — client.js injects its TypeOverrides into every Result), and the global module itself. User-supplied types objects (per-query types, or TypeOverrides wrapping custom userTypes) keep the existing uncached path, since their getTypeParser behavior can change without notice.
  • Capped at 1000 shapes per types object (cleared wholesale beyond that, like a generation flip).

Microbenchmark (new Result + addFields + one parseRow, 20 columns, Node 26): 7.3µs → 2.9µs per query, ~2.6× faster result processing on repeated shapes.

Global setTypeParser coverage

Registrations on the global module — pg.types.setTypeParser(...), defaults.parseInt8, or a direct require('pg-types') — all mutate the same module object, so its setTypeParser is wrapped once (in result.js, at first pg load) to bump the same version counter. The parseInt8 integration test, which toggles the global parser between same-shape queries, passes. The one remaining bypass is code that saved a reference to the unwrapped setTypeParser before pg was first required and calls it after queries have run; a version counter inside pg-types itself would close even that, and I'm happy to send that one-liner to brianc/node-pg-types as a follow-up if you want it.

Tests

  • packages/pg/test/unit/client/field-metadata-cache-tests.js: shape reuse, distinct shapes, name/oid/format keying, setTypeParser invalidation, per-instance isolation, custom-types bypass (both flavors), and a client-level test that client.setTypeParser between two same-shape queries applies to the second.
  • New unit case covering the global-module path: pg.types.setTypeParser between two same-shape queries applies to the second.
  • Full existing unit suite passes (two pre-existing failures on my machine — getaddrinfo ENOTFOUND localhost — fail identically on unmodified master).
  • Wire-level smoke against a real Postgres 16: repeated shapes, multi-statement queries (two RowDescriptions in one action), rowMode: 'array', named prepared statements, and live setTypeParser invalidation in both row modes.

@Iaotle
Iaotle force-pushed the perf/cache-field-metadata-per-query-shape branch from 0167057 to 9c67c03 Compare August 18, 2026 11:10
@Iaotle
Iaotle marked this pull request as ready for review August 18, 2026 11:21
Real workloads repeat a small set of result-set shapes, so addFields
rebuilding the parser array and prebuilt empty row on every query is
measurable overhead at high query rates. Cache both per shape, keyed on
the types object, invalidated by a version counter that setTypeParser
bumps. Only versioned types objects (TypeOverrides backed by the global
pg-types module, or the module itself) use the cache; user-supplied
types objects keep the uncached path.

~2.6x faster result processing for a cached 20-column single-row shape
(7.3 -> 2.9 us per query in a addFields+parseRow microbenchmark).
@Iaotle
Iaotle force-pushed the perf/cache-field-metadata-per-query-shape branch from 9c67c03 to 2b4e8d7 Compare August 18, 2026 11:29
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