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
@brianc

brianc commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Thanks for the PR and sorry for the delay! Been out of town for a while dealing w/ some stuff... I need to think about this one a bit more - it definitely makes sense in a lot of scenarios. Would it be possible to run the benchmark suite that @nigrosimone wrote with these changes & see how it impacts? I have a few very minor concerns like the seemingly reasonable but also seemingly arbitrary 1000 cache limit on number of queries. Overall on the surface it makes total sense. I'll sleep on it a bit and see if there are any weird end-cases throughout the years that could crop back up pop into my mind. FWIW A long time ago I used to build a constructor object with eval for each query which sped up subsequent rows but leaked some memory and had some hard to track down problems and the perf gains were minor. I always wary introducing something that might end up w/ more allocations in large production apps so...I'll take some caution here.

@nigrosimone

Copy link
Copy Markdown
Contributor

@Iaotle Brian talk about this benchmark https://github.com/nigrosimone/postgres-benchmarks you can fork and patch pg with your pr.

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.

4 participants