From 2b4e8d7859aaf5d1df7ecc42e3ec276249a5f074 Mon Sep 17 00:00:00 2001 From: Iaotle <43072246+Iaotle@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:57:07 +0200 Subject: [PATCH] perf(pg): cache field parsers and prebuilt rows per query shape 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). --- packages/pg/lib/result.js | 79 ++++++++- packages/pg/lib/type-overrides.js | 3 + .../unit/client/field-metadata-cache-tests.js | 162 ++++++++++++++++++ 3 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 packages/pg/test/unit/client/field-metadata-cache-tests.js diff --git a/packages/pg/lib/result.js b/packages/pg/lib/result.js index 329fbf9fc..f97228c9d 100644 --- a/packages/pg/lib/result.js +++ b/packages/pg/lib/result.js @@ -1,9 +1,32 @@ 'use strict' const types = require('pg-types') +const TypeOverrides = require('./type-overrides') const matchRegexp = /^([A-Za-z]+)(?: (\d+))?(?: (\d+))?/ +// Real workloads repeat a small set of result-set shapes, so the parser array and +// prebuilt empty row for a shape are cached per types object instead of rebuilt on +// every query. Entries are only cached against types objects whose registrations are +// versioned — TypeOverrides instances (see setTypeParser there) and the pg-types +// module — so late setTypeParser calls invalidate; arbitrary user-supplied types +// objects keep the uncached path since their behavior can change without notice. +const fieldMetadataCache = new WeakMap() +const FIELD_METADATA_CACHE_LIMIT = 1000 + +// Registrations on the global module must invalidate cached field metadata no matter +// how they arrive — pg.types.setTypeParser, defaults.parseInt8, or a direct pg-types +// require all mutate the same module object — so its setTypeParser is wrapped once +// to count them. +if (!types.__setTypeParserWrapped) { + const setTypeParser = types.setTypeParser + types.setTypeParser = function () { + types.__typeParserVersion = (types.__typeParserVersion || 0) + 1 + return setTypeParser.apply(types, arguments) + } + types.__setTypeParserWrapped = true +} + // result object returned from query // in the 'end' event and also // passed as second argument to provided callback @@ -85,21 +108,61 @@ class Result { // of rowDescriptions...eg: 'select NOW(); select 1::int;' // you need to reset the fields this.fields = fieldDescriptions - if (this.fields.length) { - this._parsers = new Array(fieldDescriptions.length) + if (!fieldDescriptions.length) { + this._prebuiltEmptyResultObject = {} + return + } + + const typesInstance = this._types || types + const cacheable = + typesInstance === types || (typesInstance instanceof TypeOverrides && typesInstance._types === types) + if (!cacheable) { + this._buildFieldMetadata(fieldDescriptions, typesInstance) + return + } + + // The global module's version is always folded in: TypeOverrides.getTypeParser + // falls back to it for any oid without an instance-level override. + const version = (typesInstance.__typeParserVersion || 0) + (types.__typeParserVersion || 0) + let cacheEntry = fieldMetadataCache.get(typesInstance) + if (!cacheEntry || cacheEntry.version !== version) { + cacheEntry = { version, map: new Map() } + fieldMetadataCache.set(typesInstance, cacheEntry) + } + + // Postgres identifiers and wire strings cannot contain NUL, so '\0' cannot + // collide with field names. + let signature = '' + for (let i = 0; i < fieldDescriptions.length; i++) { + const desc = fieldDescriptions[i] + signature += desc.name + '\0' + desc.dataTypeID + '\0' + (desc.format || 'text') + '\0' + } + + const cached = cacheEntry.map.get(signature) + if (cached) { + this._parsers = cached.parsers + this._prebuiltEmptyResultObject = cached.prebuiltEmptyResultObject + return + } + + this._buildFieldMetadata(fieldDescriptions, typesInstance) + if (cacheEntry.map.size >= FIELD_METADATA_CACHE_LIMIT) { + cacheEntry.map.clear() } + cacheEntry.map.set(signature, { + parsers: this._parsers, + prebuiltEmptyResultObject: this._prebuiltEmptyResultObject, + }) + } + _buildFieldMetadata(fieldDescriptions, typesInstance) { + this._parsers = new Array(fieldDescriptions.length) const row = Object.create(null) for (let i = 0; i < fieldDescriptions.length; i++) { const desc = fieldDescriptions[i] row[desc.name] = null - - if (this._types) { - this._parsers[i] = this._types.getTypeParser(desc.dataTypeID, desc.format || 'text') - } else { - this._parsers[i] = types.getTypeParser(desc.dataTypeID, desc.format || 'text') - } + this._parsers[i] = typesInstance.getTypeParser(desc.dataTypeID, desc.format || 'text') } this._prebuiltEmptyResultObject = { ...row } diff --git a/packages/pg/lib/type-overrides.js b/packages/pg/lib/type-overrides.js index 9d219e525..1385e7e3f 100644 --- a/packages/pg/lib/type-overrides.js +++ b/packages/pg/lib/type-overrides.js @@ -25,6 +25,9 @@ TypeOverrides.prototype.setTypeParser = function (oid, format, parseFn) { format = 'text' } this.getOverrides(format)[oid] = parseFn + // Invalidates any per-query-shape field metadata cached against this instance + // (see result.js's addFields). + this.__typeParserVersion = (this.__typeParserVersion || 0) + 1 } TypeOverrides.prototype.getTypeParser = function (oid, format) { diff --git a/packages/pg/test/unit/client/field-metadata-cache-tests.js b/packages/pg/test/unit/client/field-metadata-cache-tests.js new file mode 100644 index 000000000..b9d4ba128 --- /dev/null +++ b/packages/pg/test/unit/client/field-metadata-cache-tests.js @@ -0,0 +1,162 @@ +'use strict' +const assert = require('assert') +const helper = require('./test-helper') +const Result = require('../../../lib/result') +const TypeOverrides = require('../../../lib/type-overrides') + +const suite = new helper.Suite() +const test = suite.test.bind(suite) + +const intField = { name: 'a', dataTypeID: 23, format: 'text' } +const textField = { name: 'b', dataTypeID: 25, format: 'text' } + +test('same query shape reuses the cached parser array and prebuilt row', function () { + const types = new TypeOverrides() + const first = new Result('', types) + first.addFields([intField, textField]) + const second = new Result('', types) + second.addFields([intField, textField]) + + assert.strictEqual(second._parsers, first._parsers) + assert.strictEqual(second._prebuiltEmptyResultObject, first._prebuiltEmptyResultObject) + assert.deepStrictEqual(second.parseRow(['42', 'hi']), { a: 42, b: 'hi' }) +}) + +test('different query shapes get their own metadata', function () { + const types = new TypeOverrides() + const first = new Result('', types) + first.addFields([intField]) + const second = new Result('', types) + second.addFields([textField]) + + assert.notStrictEqual(second._parsers, first._parsers) + assert.deepStrictEqual(first.parseRow(['1']), { a: 1 }) + assert.deepStrictEqual(second.parseRow(['1']), { b: '1' }) +}) + +test('field name, dataTypeID and format all key the cache', function () { + const types = new TypeOverrides() + const first = new Result('', types) + first.addFields([{ name: 'a', dataTypeID: 23, format: 'text' }]) + const second = new Result('', types) + second.addFields([{ name: 'a', dataTypeID: 25, format: 'text' }]) + + assert.notStrictEqual(second._parsers, first._parsers) + assert.deepStrictEqual(first.parseRow(['7']), { a: 7 }) + assert.deepStrictEqual(second.parseRow(['7']), { a: '7' }) +}) + +test('setTypeParser invalidates cached shapes on that instance', function () { + const types = new TypeOverrides() + const before = new Result('', types) + before.addFields([intField]) + assert.deepStrictEqual(before.parseRow(['42']), { a: 42 }) + + types.setTypeParser(23, 'text', (value) => `parsed:${value}`) + + const after = new Result('', types) + after.addFields([intField]) + assert.notStrictEqual(after._parsers, before._parsers) + assert.deepStrictEqual(after.parseRow(['42']), { a: 'parsed:42' }) +}) + +test('global pg-types setTypeParser between queries applies to later same-shape queries', function () { + const pgTypes = require('pg-types') + const bigintField = { name: 'big', dataTypeID: 20, format: 'text' } + const originalParser = pgTypes.getTypeParser(20, 'text') + const types = new TypeOverrides() + + const before = new Result('', types) + before.addFields([bigintField]) + assert.deepStrictEqual(before.parseRow(['1']), { big: originalParser('1') }) + + pgTypes.setTypeParser(20, 'text', (value) => `G:${value}`) + try { + const after = new Result('', types) + after.addFields([bigintField]) + assert.deepStrictEqual(after.parseRow(['1']), { big: 'G:1' }) + } finally { + pgTypes.setTypeParser(20, 'text', originalParser) + } +}) + +test('separate TypeOverrides instances do not share cached parsers', function () { + const typesA = new TypeOverrides() + typesA.setTypeParser(23, 'text', (value) => `A:${value}`) + const typesB = new TypeOverrides() + + const resultA = new Result('', typesA) + resultA.addFields([intField]) + const resultB = new Result('', typesB) + resultB.addFields([intField]) + + assert.deepStrictEqual(resultA.parseRow(['1']), { a: 'A:1' }) + assert.deepStrictEqual(resultB.parseRow(['1']), { a: 1 }) +}) + +test('custom user-supplied types objects bypass the cache', function () { + let calls = 0 + const customTypes = { + getTypeParser() { + calls++ + return (value) => `custom:${value}` + }, + } + + const first = new Result('', customTypes) + first.addFields([intField]) + const second = new Result('', customTypes) + second.addFields([intField]) + + assert.strictEqual(calls, 2) + assert.notStrictEqual(second._parsers, first._parsers) + assert.deepStrictEqual(second.parseRow(['1']), { a: 'custom:1' }) +}) + +test('a TypeOverrides wrapping custom user types bypasses the cache', function () { + let calls = 0 + const customTypes = { + getTypeParser() { + calls++ + return (value) => `custom:${value}` + }, + } + const types = new TypeOverrides(customTypes) + + const first = new Result('', types) + first.addFields([intField]) + const second = new Result('', types) + second.addFields([intField]) + + assert.strictEqual(calls, 2) + assert.deepStrictEqual(second.parseRow(['1']), { a: 'custom:1' }) +}) + +test('client.setTypeParser between queries applies to later same-shape queries', function (done) { + const client = helper.client() + const con = client.connection + + client.query('select 1', (err, result) => { + assert.ifError(err) + assert.deepStrictEqual(result.rows, [{ a: 42 }]) + + client.setTypeParser(23, 'text', (value) => `overridden:${value}`) + + client.query('select 1', (err2, result2) => { + assert.ifError(err2) + assert.deepStrictEqual(result2.rows, [{ a: 'overridden:42' }]) + done() + }) + + con.emit('rowDescription', { fields: [intField] }) + con.emit('dataRow', { fields: ['42'] }) + con.emit('commandComplete', { text: 'SELECT 1' }) + con.emit('readyForQuery') + }) + + con.emit('readyForQuery') + con.emit('rowDescription', { fields: [intField] }) + con.emit('dataRow', { fields: ['42'] }) + con.emit('commandComplete', { text: 'SELECT 1' }) + con.emit('readyForQuery') +})