From 389031c7ce56d568b48c7eed2895dd629deeee74 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:09:05 +0100 Subject: [PATCH 01/34] feat: publish query, connection and transaction tracing channels (#73) APM and OpenTelemetry integrations had to patch the driver's source, which breaks whenever the code shape moves and does not cover the fork's 4.x line. The driver now publishes diagnostics_channel TracingChannels postgres:query, postgres:connection and postgres:transaction, plus the plain channels postgres:query:send and postgres:connection:close. A query starts in Query.handle() through start.runStores in the caller's synchronous context, so stores bound to the start channel are current for the code that runs the query. The settlement events come from the query's own resolve and reject, not from wrapping the Query in tracePromise: a cursor read with for await never settles the Query itself, and the promise tracePromise returns would reject unhandled. The cursor's replacement resolve and reject publish too, and a COPY stream publishes when the copy is over, not when the stream is handed out. Each query settles once, whatever path it takes. The query text, parameters, connection id and server address are filled in when the connection builds the query, because serializers depend on fetched types. postgres:query:send is published at that point with the same context. The connection span covers one acquisition, from the first attempt to the hand-off or the failure that ends it. sql.begin() and sql.savepoint() publish on the transaction channel. With no subscriber a query costs one hasSubscribers check and allocates nothing. resolve and reject stay single-expression arrows for dd-trace, and the build, toBuffer and handler shapes it rewrites are unchanged. The Deno build maps the module to node:diagnostics_channel and leaves the new test file out, as Deno's std shim has neither. --- src/connection.js | 28 ++++++++++++- src/index.js | 32 ++++++++++++--- src/query.js | 26 ++++++++++--- src/tracing.js | 93 ++++++++++++++++++++++++++++++++++++++++++++ tests-types/index.ts | 6 +++ transpile.deno.js | 7 +++- types/index.d.ts | 49 +++++++++++++++++++++++ 7 files changed, 227 insertions(+), 14 deletions(-) create mode 100644 src/tracing.js diff --git a/src/connection.js b/src/connection.js index 4e05655b..5923e6cf 100644 --- a/src/connection.js +++ b/src/connection.js @@ -12,6 +12,7 @@ import clampedTimeout from './timeout.js' import { SSLRequest, tlsConfig, cancelRequest } from './transport.js' import { Query, CLOSE } from './query.js' import b from './bytes.js' +import { connectionChannel, watched, run, resolved, rejected, serverOf, querySent, connectionClosed } from './tracing.js' export { Connection } @@ -113,6 +114,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose , statementCount = 1 , remaining = 0 , hostIndex = 0 + , serverIndex = 0 , length = 0 , rows = 0 , copyPaused = false @@ -297,12 +299,28 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose return backoffSeconds(backoff, options.shared.retries) * 1000 } + function serverAddress() { + return options.path || host[serverIndex] + } + + function serverPort() { + return options.path ? undefined : port[serverIndex] + } + function standbyPass() { return target_session_attrs === 'prefer-standby' && host.length > 1 && acquisition.pass === 'standby' } function acquire(owner) { - acquisition = { owner, ending: false, pass: 'standby', hostsTried: 0, attempting: null, mismatches: [], lastError: null } + acquisition = { owner, ending: false, pass: 'standby', hostsTried: 0, attempting: null, mismatches: [], lastError: null, trace: undefined } + if (!watched(connectionChannel)) + return startAcquisition() + + acquisition.trace = { connectionId: id, database, user, ...serverOf(options) } + run(connectionChannel, acquisition.trace, startAcquisition) + } + + function startAcquisition() { const wait = inheritedBackoff ? inheritedBackoff.at + inheritedBackoff.delay - performance.now() : 0 inheritedBackoff = null wait > 0 ? enterBackoff(wait) : enterConnecting() @@ -345,6 +363,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose function attach(created) { listen(created) socket = created + serverIndex = hostIndex + acquisition.trace && (acquisition.trace.serverAddress = serverAddress(), acquisition.trace.serverPort = serverPort()) acquisition.attempting = options.socket ? 'custom socket' : options.path || host[hostIndex] + ':' + port[hostIndex] if (options.socket) @@ -488,8 +508,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose acquisition = null endWaiters = [] transition(Phase.Closed) + a && a.trace && rejected(connectionChannel, a.trace, err) a && queryError(a.owner, err) waiters.forEach(resolve => resolve()) + connectionClosed(id, reported) onclose(connection, reported) } @@ -516,10 +538,11 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function handoff() { - const { owner, ending } = acquisition + const { owner, ending, trace } = acquisition transition(Phase.Ready) clearAcquisitionTimers() acquisition = null + trace && resolved(connectionChannel, trace) options.shared.retries = 0 lifeTimer.start() @@ -645,6 +668,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose : { string, types, name: q.prepare ? statementId + statementCount++ : '' } typeof options.debug === 'function' && callback(options.debug, id, string, parameters, types) + q.trace && querySent(q, id, serverAddress(), serverPort()) } function write(x, fn) { diff --git a/src/index.js b/src/index.js index db93ccaa..c16beb85 100644 --- a/src/index.js +++ b/src/index.js @@ -28,6 +28,7 @@ import Queue from './queue.js' import { Errors, PostgresError, callback } from './errors.js' import Subscribe from './subscribe.js' import largeObject from './large.js' +import { transactionChannel, watched, traceAsync } from './tracing.js' const Lease = { Active: 0, Settling: 1, Released: 2, Ended: 3, Closed: 4 } @@ -94,6 +95,7 @@ function Postgres(a, b) { function Sql(handler) { handler.debug = options.debug + handler.config = options Object.entries(options.types).reduce((acc, [name, type]) => { acc[name] = (x) => new Parameter(x, type.to) @@ -137,7 +139,7 @@ function Postgres(a, b) { function file(path, args = [], options = {}) { arguments.length === 2 && !Array.isArray(args) && (options = args, args = []) - const query = new Query([], args, (query) => { + const read = query => { fs.readFile(path, 'utf8', (err, string) => { if (err) return query.reject(err) @@ -145,7 +147,9 @@ function Postgres(a, b) { query.strings = [string] handler(query) }) - }, cancel, { + } + read.config = handler.config + const query = new Query([], args, read, cancel, { ...options, simple: 'simple' in options ? options.simple : args.length === 0 }) @@ -346,8 +350,20 @@ function Postgres(a, b) { } } - async function begin(options, fn) { - !fn && (fn = options, options = '') + function begin(mode, fn) { + !fn && (fn = mode, mode = '') + if (!watched(transactionChannel)) + return transact(mode, fn) + + const trace = transactionTrace(mode) + return traceAsync(transactionChannel, trace, () => transact(mode, fn, trace)) + } + + function transactionTrace(mode, savepoint) { + return { database: options.database, user: options.user, options: mode, savepoint, prepared: undefined, result: undefined } + } + + async function transact(options, fn, trace) { const l = await lease(true) let savepoints = 0 , prepare = null @@ -395,6 +411,7 @@ function Postgres(a, b) { throw Errors.generic('INVALID_TRANSACTION_NAME', 'sql.prepare requires a non-empty string without null bytes') prepare = 'E\'' + x.replace(/\\/g, '\\\\').replace(/'/g, '\'\'') + '\'' + trace && (trace.prepared = x) } let uncaughtError , result @@ -430,6 +447,8 @@ function Postgres(a, b) { uncaughtError && { cause: uncaughtError } ) } + + trace && (trace.result = { committed: prepare === null }) } return result @@ -439,7 +458,10 @@ function Postgres(a, b) { return savepoint(sql => sql.apply(sql, arguments)) arguments.length === 1 && (fn = name, name = null) - return scope(fn, 's' + savepoints++ + (name ? '_' + name : '')) + const id = 's' + savepoints++ + (name ? '_' + name : '') + return watched(transactionChannel) + ? traceAsync(transactionChannel, transactionTrace(undefined, id), () => scope(fn, id)) + : scope(fn, id) } function track(q) { diff --git a/src/query.js b/src/query.js index c1777a90..b7bd3e66 100644 --- a/src/query.js +++ b/src/query.js @@ -1,3 +1,5 @@ +import { queryChannel, watched, traceQuery, querySettled, queryFailed } from './tracing.js' + const originCache = new Map() , originStackCache = new Map() , originError = Symbol('OriginError') @@ -24,13 +26,14 @@ export class Query extends Promise { this.statement = null // dd-trace's load-time rewrite only accepts single-expression arrows here - this.resolve = x => (this.active = false, this.settled = true, resolve(x)) - this.reject = x => (this.active = false, this.settled = true, reject(x)) + this.resolve = x => (this.trace && querySettled(this, x), this.active = false, this.settled = true, resolve(x)) + this.reject = x => (this.trace && queryFailed(this, x), this.active = false, this.settled = true, reject(x)) this.active = false this.settled = false this.cancelled = null this.executed = false + this.trace = undefined this.signature = '' this[originError] = this.handler.debug @@ -105,8 +108,8 @@ export class Query extends Promise { resolve({ value, done: false }) return new Promise(r => prev = r) } - this.resolve = () => (this.active = false, resolve({ done: true })) - this.reject = x => (this.active = false, reject(x)) + this.resolve = x => (this.trace && querySettled(this, x), this.active = false, resolve({ done: true })) + this.reject = x => (this.trace && queryFailed(this, x), this.active = false, reject(x)) }) this.execute() return promise @@ -146,7 +149,15 @@ export class Query extends Promise { } async handle() { - !this.executed && (this.executed = true) && await 1 && this.handler(this) + if (this.executed) + return + + this.executed = true + if (!this.initialization && watched(queryChannel)) + return traceQuery(this, dispatch) + + await 1 + this.handler(this) } execute() { @@ -170,6 +181,11 @@ export class Query extends Promise { } } +async function dispatch(q) { + await 1 + q.handler(q) +} + function cachedError(xs) { if (originCache.has(xs)) return originCache.get(xs) diff --git a/src/tracing.js b/src/tracing.js new file mode 100644 index 00000000..141a8bf2 --- /dev/null +++ b/src/tracing.js @@ -0,0 +1,93 @@ +import dc from 'diagnostics_channel' + +const supported = Boolean(dc) && typeof dc.tracingChannel === 'function' + +export const queryChannel = supported ? dc.tracingChannel('postgres:query') : undefined +export const connectionChannel = supported ? dc.tracingChannel('postgres:connection') : undefined +export const transactionChannel = supported ? dc.tracingChannel('postgres:transaction') : undefined +export const sendChannel = supported ? dc.channel('postgres:query:send') : undefined +export const closeChannel = supported ? dc.channel('postgres:connection:close') : undefined + +export function watched(channel) { + return channel !== undefined && (channel.hasSubscribers ?? channel.start.hasSubscribers) +} + +export function run(channel, context, fn, ...args) { + try { + return channel.start.runStores(context, fn, undefined, ...args) + } finally { + channel.end.publish(context) + } +} + +export function resolved(channel, context) { + channel.asyncStart.publish(context) + channel.asyncEnd.publish(context) +} + +export function rejected(channel, context, error) { + context.error = error + channel.error.publish(context) + resolved(channel, context) +} + +export function traceAsync(channel, context, fn) { + return run(channel, context, fn).then( + result => (resolved(channel, context), result), + error => { + rejected(channel, context, error) + throw error + } + ) +} + +export function serverOf({ host, port, path }) { + return path + ? { serverAddress: path, serverPort: undefined } + : host.length === 1 + ? { serverAddress: host[0], serverPort: port[0] } + : { serverAddress: undefined, serverPort: undefined } +} + +export function traceQuery(q, fn) { + const config = q.handler.config + q.trace = { + query: undefined, + values: undefined, + database: config && config.database, + user: config && config.user, + ...config && serverOf(config), + connectionId: undefined + } + return run(queryChannel, q.trace, fn, q) +} + +export function querySent(q, connectionId, serverAddress, serverPort) { + const context = q.trace + context.query = q.string + context.values = q.parameters + context.connectionId = connectionId + context.serverAddress = serverAddress + context.serverPort = serverPort + sendChannel.hasSubscribers && sendChannel.publish(context) +} + +export function querySettled(q, result) { + if (q.streaming && result && typeof result.pipe === 'function') + return + + const context = q.trace + q.trace = undefined + context.result = result + resolved(queryChannel, context) +} + +export function queryFailed(q, error) { + const context = q.trace + q.trace = undefined + rejected(queryChannel, context, error) +} + +export function connectionClosed(connectionId, error) { + closeChannel && closeChannel.hasSubscribers && closeChannel.publish({ connectionId, error }) +} diff --git a/tests-types/index.ts b/tests-types/index.ts index d1d7c719..3f45717b 100644 --- a/tests-types/index.ts +++ b/tests-types/index.ts @@ -27,3 +27,9 @@ new postgres.PostgresError({ code: '23505' }) new postgres.PostgresError('boom') // @ts-expect-error new postgres.PostgresError(42) + +const queryTrace: postgres.QueryTraceContext = { query: undefined, values: undefined, database: 'a', user: 'b', serverAddress: undefined, serverPort: undefined, connectionId: undefined } +const connectionTrace: postgres.ConnectionTraceContext = { connectionId: 1, database: 'a', user: 'b', serverAddress: 'localhost', serverPort: 5432 } +const transactionTrace: postgres.TransactionTraceContext = { database: 'a', user: 'b', options: 'read write', savepoint: undefined, prepared: undefined, result: { committed: true } } +const closeMessage: postgres.ConnectionCloseMessage = { connectionId: 1, error: undefined } +void [queryTrace, connectionTrace, transactionTrace, closeMessage] diff --git a/transpile.deno.js b/transpile.deno.js index 25ea9dac..2eea9e45 100644 --- a/transpile.deno.js +++ b/transpile.deno.js @@ -25,7 +25,7 @@ fs.readdirSync('src').forEach(name => ) ) -fs.readdirSync('tests').forEach(name => +fs.readdirSync('tests').filter(name => name !== 'tracing.js').forEach(name => fs.writeFileSync( path.join(tests, name), name.endsWith('.js') @@ -44,8 +44,10 @@ function transpile(x, name, folder) { .replace(/\nexec\(/g, '\nawait exec(') .replace('{ spawnSync }', '{ spawn }') } - if (name === 'index.js') + if (name === 'index.js') { + x = x.replace('import \'./tracing.js\'\n', '') x += '\n;globalThis.addEventListener("unload", () => Deno.exit(process.exitCode))' + } } const buffer = x.includes('Buffer') @@ -74,6 +76,7 @@ function transpile(x, name, folder) { '(query.writable.push({ chunk }), callback())' ) .replace('socket.setKeepAlive(true, 1000 * keep_alive)', 'socket.setKeepAlive(true)') + .replace('from \'diagnostics_channel\'', 'from \'node:diagnostics_channel\'') .replace('node:stream', std + 'node/stream.ts') .replace('import net from \'net\'', 'import { net } from \'../polyfills.js\'') .replace('import tls from \'tls\'', 'import { tls } from \'../polyfills.js\'') diff --git a/types/index.d.ts b/types/index.d.ts index 7574d4b0..e1e88818 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -798,6 +798,55 @@ declare namespace postgres { prepare(name: string): Promise>; } + /** Context of the `postgres:query` tracing channel. `query`, `values`, `serverAddress`, `serverPort` and `connectionId` are set when the query is written to a connection, after `start`. */ + interface QueryTraceContext { + /** SQL text with `$n` placeholders as sent to the server */ + query: string | undefined; + /** Parameters before they are serialized for the wire, raw */ + values: unknown[] | undefined; + database: string; + user: string; + serverAddress: string | undefined; + serverPort: number | undefined; + connectionId: number | undefined; + /** Set before `asyncStart` when the query succeeded */ + result?: ResultMeta | Statement | Readable | Writable; + /** Set before `error` when the query failed */ + error?: Error; + } + + /** Context of the `postgres:connection` tracing channel, one span for each connection acquisition. */ + interface ConnectionTraceContext { + connectionId: number; + database: string; + user: string; + /** The host of the latest attempt; undefined before the first attempt when several hosts are configured */ + serverAddress: string | undefined; + serverPort: number | undefined; + error?: Error; + } + + /** Context of the `postgres:transaction` tracing channel, for `sql.begin()` and `sql.savepoint()`. */ + interface TransactionTraceContext { + database: string; + user: string; + /** The mode string passed to `sql.begin()`; undefined for a savepoint */ + options: string | undefined; + /** The savepoint name as sent to the server; undefined for `sql.begin()` */ + savepoint: string | undefined; + /** The name given to `sql.prepare()` */ + prepared: string | undefined; + /** Set by `sql.begin()` on success: `committed` is false when the transaction was prepared */ + result: { committed: boolean } | undefined; + error?: Error; + } + + /** Payload of the `postgres:connection:close` channel. */ + interface ConnectionCloseMessage { + connectionId: number; + error: Error | undefined; + } + interface ReservedSql = {}> extends Sql { release(): void; From b8230d94c4388341c8550c23e8924ee62e110dcc Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:09:10 +0100 Subject: [PATCH 02/34] test: cover the tracing channels against PostgreSQL and in workerd The new tests subscribe to the channels and check the event order and the context for a plain query, a failing one, cursors read with a callback and with for await (to the end and on break), forEach, COPY in both directions, describe, simple, unsafe, file, pipelined queries, a query cancelled before it is sent, a reserved connection, a transaction with a savepoint and one that rolls back, and a connection that succeeds, fails to authenticate and is refused. They also check that a store bound to start is current where the query and the transaction run, and that nothing is published while nothing subscribes. The workerd scenario runs at every compatibility date in the matrix. It asserts that tracingChannel, runStores and bindStore exist there and that one query publishes the connection and query events in order. --- tests-startup/workerd-edge.mjs | 2 +- tests-startup/workerd-run.mjs | 2 +- tests-startup/workerd-worker.js | 33 ++++ tests/index.js | 1 + tests/tracing.js | 306 ++++++++++++++++++++++++++++++++ 5 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 tests/tracing.js diff --git a/tests-startup/workerd-edge.mjs b/tests-startup/workerd-edge.mjs index d00446fe..98a40451 100644 --- a/tests-startup/workerd-edge.mjs +++ b/tests-startup/workerd-edge.mjs @@ -6,7 +6,7 @@ import path from 'node:path' const scenarios = [ 'arrays-false', 'arrays-true', 'timers', 'end-timeout', 'end-plain', 'end-while-connecting', 'end-tls', - 'copy-out', 'subscribe', 'diagnostics-channel', 'ssl-verify', 'tls-private-ca', 'pooled', 'no-end', + 'copy-out', 'subscribe', 'diagnostics-channel', 'tracing-channels', 'ssl-verify', 'tls-private-ca', 'pooled', 'no-end', 'abort-neon', 'abort-neon-pooled', 'abort-supabase-pooler', 'abort-hyperdrive', 'abort-hyperdrive-pooled', 'abort-hyperdrive-supabase' ] const hyperdrive = [ diff --git a/tests-startup/workerd-run.mjs b/tests-startup/workerd-run.mjs index da5db830..b1b3502e 100644 --- a/tests-startup/workerd-run.mjs +++ b/tests-startup/workerd-run.mjs @@ -86,7 +86,7 @@ async function main() { : [ 'cold-reserve-false', 'arrays-false', 'arrays-true', 'catalog-error', 'timers', 'connect-timeout', 'end-timeout', 'end-plain', 'end-while-connecting', - 'copy-out', 'ssl-prefer-declined', 'subscribe', 'diagnostics-channel', 'no-end', + 'copy-out', 'ssl-prefer-declined', 'subscribe', 'diagnostics-channel', 'tracing-channels', 'no-end', ...(tlsPort && process.env.WORKERD_TLS_CA ? tlsScenarios : []) ] for (const name of scenarios) { diff --git a/tests-startup/workerd-worker.js b/tests-startup/workerd-worker.js index 147f60f3..dbe0aacc 100644 --- a/tests-startup/workerd-worker.js +++ b/tests-startup/workerd-worker.js @@ -181,6 +181,39 @@ const scenarios = { } return { tracingChannel: Boolean(tracing), events } }, + async 'tracing-channels'(port) { + const dc = await import('node:diagnostics_channel') + const { AsyncLocalStorage } = await import('node:async_hooks') + const storage = new AsyncLocalStorage() + const query = dc.tracingChannel('postgres:query') + const connection = dc.tracingChannel('postgres:connection') + equal([typeof dc.tracingChannel, typeof query.start.runStores, typeof query.start.bindStore], ['function', 'function', 'function']) + const seen = [] + const handlers = (name) => ({ + start: () => seen.push(name + ':start:' + storage.getStore()), + end: () => seen.push(name + ':end'), + asyncStart: () => seen.push(name + ':asyncStart'), + asyncEnd: (context) => seen.push(name + ':asyncEnd:' + (context.query || context.serverAddress)), + error: (context) => seen.push(name + ':error:' + context.error.message) + }) + const subscribed = [[query, handlers('query')], [connection, handlers('connection')]] + query.start.bindStore(storage, () => 'span') + subscribed.forEach(([channel, handler]) => channel.subscribe(handler)) + const sql = client(port, { max: 1 }) + try { + await within(sql`select 1 as x`, 5000, 'traced query') + } finally { + subscribed.forEach(([channel, handler]) => channel.unsubscribe(handler)) + query.start.unbindStore(storage) + await within(sql.end(), 3000, 'end') + } + const address = sql.options.host[0] + equal(seen, [ + 'query:start:span', 'query:end', 'connection:start:span', 'connection:end', 'connection:asyncStart', 'connection:asyncEnd:' + address, + 'query:asyncStart', 'query:asyncEnd:select 1 as x' + ]) + return { seen } + }, async 'tls-private-ca'() { const results = [] for (const ssl of ['require', 'prefer']) { diff --git a/tests/index.js b/tests/index.js index 311330b8..631a4b8f 100644 --- a/tests/index.js +++ b/tests/index.js @@ -7,6 +7,7 @@ import crypto from 'crypto' import postgres from '../src/index.js' import { vercelPool } from '../src/vercel.js' +import './tracing.js' const delay = ms => new Promise(r => setTimeout(r, ms)) const rel = x => new URL(x, import.meta.url) diff --git a/tests/tracing.js b/tests/tracing.js new file mode 100644 index 00000000..58ca24c9 --- /dev/null +++ b/tests/tracing.js @@ -0,0 +1,306 @@ +import dc from 'diagnostics_channel' +import { AsyncLocalStorage } from 'async_hooks' + +import { t } from './test.js' +import postgres from '../src/index.js' + +const rel = x => new URL(x, import.meta.url) +const stages = ['start', 'end', 'asyncStart', 'asyncEnd', 'error'] + +const options = { + db: 'postgres_js_test', + user: 'postgres_js_test', + idle_timeout: 1, + connect_timeout: 1, + max: 1 +} + +function collect(...names) { + const events = [] + const undo = [] + const ids = new Map() + const id = context => ids.get(context) || (ids.set(context, ids.size + 1), ids.size) + for (const name of names.filter(x => x.startsWith('postgres:'))) { + const handler = context => events.push({ name, stage: 'publish', id: id(context), context: { ...context } }) + dc.subscribe(name, handler) + undo.push(() => dc.unsubscribe(name, handler)) + } + + for (const name of names.filter(x => !x.startsWith('postgres:'))) { + const channel = dc.tracingChannel('postgres:' + name) + const handlers = Object.fromEntries(stages.map(stage => + [stage, context => events.push({ name, stage, id: id(context), context: { ...context } })] + )) + channel.subscribe(handlers) + undo.push(() => channel.unsubscribe(handlers)) + } + + return { events, stop: () => undo.forEach(x => x()) } +} + +const of = (events, name) => events.filter(x => x.name === name) +const sequence = (events, name) => of(events, name).map(x => x.stage).join(' ') +const ended = (events, name = 'query') => of(events, name).filter(x => x.stage === 'asyncEnd').map(x => x.context) +const plain = 'start end asyncStart asyncEnd' +const failed = 'start end error asyncStart asyncEnd' +const count = (events, stage, name = 'query') => of(events, name).filter(x => x.stage === stage).length +const settled = (events, name = 'query') => JSON.stringify([count(events, 'start', name), count(events, 'asyncEnd', name)]) +const server = o => ({ address: o.path || o.host[0], port: o.path ? undefined : o.port[0] }) + +async function traced(names, fn) { + const sql = postgres(options) + const { events, stop } = collect(...names) + try { + await fn(sql) + } finally { + stop() + await sql.end() + } + return events +} + +t('Tracing publishes a query in order with the context filled in as it is built', async() => { + const sql = postgres(options) + const { events, stop } = collect('query', 'postgres:query:send') + await sql`select ${ 5 }::int as x` + stop() + await sql.end() + const [done] = ended(events) + const [send] = of(events, 'postgres:query:send') + const { address, port } = server(sql.options) + return [ + JSON.stringify([ + 'start end publish asyncStart asyncEnd', 'select $1::int as x', [5], 'SELECT', 1, + sql.options.database, sql.options.user, address, port, true, true + ]), + JSON.stringify([ + events.map(x => x.stage).join(' '), done.query, done.values, done.result.command, done.result.count, + done.database, done.user, done.serverAddress, done.serverPort, typeof done.connectionId === 'number', + send.context.connectionId === done.connectionId + ]) + ] +}) + +t('Tracing publishes error before asyncStart and keeps the error on the context', async() => { + const events = await traced(['query'], sql => sql`select 1/0`.catch(() => undefined)) + const [done] = ended(events) + return [failed + '|22012', sequence(events, 'query') + '|' + done.error.code] +}) + +t('Tracing a cursor with a callback settles once', async() => { + const events = await traced(['query'], sql => sql`select * from generate_series(1, 5)`.cursor(2, () => undefined)) + return [JSON.stringify([1, 1]), settled(events)] +}) + +t('Tracing a cursor read with for await settles once, to the end and on break', async() => { + const events = await traced(['query'], async sql => { + for await (const rows of sql`select * from generate_series(1, 5)`.cursor(2)) + rows.length + for await (const rows of sql`select * from generate_series(1, 5)`.cursor(2)) { + rows.length + break + } + }) + return [JSON.stringify([2, 2]), settled(events)] +}) + +t('Tracing forEach settles once', async() => { + const events = await traced(['query'], sql => sql`select * from generate_series(1, 5)`.forEach(() => undefined)) + return [JSON.stringify([1, 1]), settled(events)] +}) + +t('Tracing COPY streams settles each query once, when the copy is over', async() => { + const events = await traced(['query'], async sql => { + await sql`create temp table tracing_copy (x int)` + const writable = await sql`copy tracing_copy from stdin`.writable() + writable.end('1\n2\n') + await new Promise(resolve => writable.on('finish', resolve)) + const readable = await sql`copy (select * from tracing_copy order by x) to stdout`.readable() + readable.resume() + await new Promise(resolve => readable.on('end', resolve)) + await sql`select 1` + }) + const copies = ended(events).filter(x => /^copy/.test(x.query)) + return [ + JSON.stringify([4, 4, ['COPY', 'COPY']]), + JSON.stringify([count(events, 'start'), count(events, 'asyncEnd'), copies.map(x => x.result.command)]) + ] +}) + +t('Tracing describe, simple, unsafe and file settle once each', async() => { + const events = await traced(['query'], async sql => { + await sql`select 1 as x`.describe() + await sql`select 2 as x`.simple() + await sql.unsafe('select 3 as x') + await sql.unsafe('select $1::int as x', [4]) + await sql.file(rel('select.sql')) + }) + return [JSON.stringify([5, 5]), settled(events)] +}) + +t('Tracing pipelined queries settle once each with their own context', async() => { + const events = await traced(['query'], sql => Promise.all([sql`select 1 as x`, sql`select 2 as x`, sql`select 3 as x`])) + return [ + JSON.stringify(['select 1 as x', 'select 2 as x', 'select 3 as x']), + JSON.stringify(ended(events).map(x => x.query)) + ] +}) + +t('Tracing a query cancelled before it is sent settles with the cancellation and is never sent', async() => { + const sql = postgres(options) + const { events, stop } = collect('query', 'postgres:query:send') + const slow = sql`select pg_sleep(0.2)`.execute() + const waiting = sql`select 2 as x` + const code = waiting.then(() => 'resolved', e => e.code) + await waiting.cancel() + await slow + const result = await code + stop() + await sql.end() + const never = of(events, 'query').find(x => x.stage === 'asyncEnd' && x.context.query === undefined) + const own = events.filter(x => x.id === never.id) + return [ + JSON.stringify(['57014', failed]), + JSON.stringify([result, own.map(x => x.stage).join(' ')]) + ] +}) + +t('Tracing a reserved connection settles each query once', async() => { + const sql = postgres(options) + const { events, stop } = collect('query') + const reserved = await sql.reserve() + await reserved`select 1 as x` + reserved.release() + stop() + await sql.end() + return [JSON.stringify([1, 1]), settled(events)] +}) + +t('Tracing a transaction publishes begin, its queries and a savepoint', async() => { + const events = await traced(['transaction', 'query'], sql => sql.begin('read write', async sql => { + await sql`select 1 as x` + await sql.savepoint('inner', sql => sql`select 2 as x`) + })) + const [savepoint, transaction] = ended(events, 'transaction') + return [ + JSON.stringify([ + 'start end start end asyncStart asyncEnd asyncStart asyncEnd', 'read write', 's0_inner', { committed: true }, null, + ['begin read write', 'select 1 as x', 'savepoint "s0_inner"', 'select 2 as x', 'commit'] + ]), + JSON.stringify([ + sequence(events, 'transaction'), transaction.options, savepoint.savepoint, transaction.result, savepoint.result || null, + ended(events).map(x => x.query) + ]) + ] +}) + +t('Tracing a rolled back transaction publishes the error', async() => { + const events = await traced(['transaction'], sql => sql.begin(() => { + throw new Error('stop') + }).catch(() => undefined)) + const [done] = ended(events, 'transaction') + return [failed + '|stop', sequence(events, 'transaction') + '|' + done.error.message] +}) + +t('Tracing a connection publishes its attempt and the close', async() => { + const sql = postgres(options) + const { events, stop } = collect('connection', 'postgres:connection:close') + await sql`select 1` + await sql.end() + stop() + const [done] = ended(events, 'connection') + const [close] = of(events, 'postgres:connection:close') + const { address, port } = server(sql.options) + return [ + JSON.stringify([plain, true, sql.options.database, address, port, true, null]), + JSON.stringify([sequence(events, 'connection'), typeof done.connectionId === 'number', done.database, done.serverAddress, done.serverPort, + close.context.connectionId === done.connectionId, close.context.error || null]) + ] +}) + +t('Tracing a connection that fails to authenticate or to connect publishes the error', async() => { + const { events, stop } = collect('connection') + const codes = [] + const wrong = postgres({ ...options, user: 'postgres_js_test_md5', pass: 'wrong' }) + codes.push(await wrong`select 1`.catch(e => e.code)) + const refused = postgres({ ...options, host: '127.0.0.1', port: 33333 }) + codes.push(await refused`select 1`.catch(e => e.code)) + stop() + return [ + JSON.stringify([['28P01', 'ECONNREFUSED'], failed + ' ' + failed, ['28P01', 'ECONNREFUSED']]), + JSON.stringify([codes, sequence(events, 'connection'), ended(events, 'connection').map(x => x.error.code)]) + ] +}) + +t('Tracing start runs in the AsyncLocalStorage context of the caller', async() => { + const storage = new AsyncLocalStorage() + const seen = [] + let spans = 0 + const sql = postgres({ ...options, debug: () => seen.push(storage.getStore()) }) + const channel = dc.tracingChannel('postgres:query') + const handlers = { start: () => seen.push('start:' + storage.getStore()) } + channel.start.bindStore(storage, () => 'span-' + ++spans) + channel.subscribe(handlers) + try { + await sql`select 1` + seen.length = 0 + await sql`select 2` + } finally { + channel.unsubscribe(handlers) + channel.start.unbindStore(storage) + await sql.end() + } + return [JSON.stringify(['start:span-2', 'span-2']), JSON.stringify(seen)] +}) + +t('Tracing a transaction runs its function inside the transaction context', async() => { + const storage = new AsyncLocalStorage() + const sql = postgres(options) + const channel = dc.tracingChannel('postgres:transaction') + const handlers = { start: () => undefined } + channel.start.bindStore(storage, () => 'transaction') + channel.subscribe(handlers) + let seen + try { + await sql.begin(async() => { + seen = storage.getStore() + }) + } finally { + channel.unsubscribe(handlers) + channel.start.unbindStore(storage) + await sql.end() + } + return ['transaction', seen] +}) + +t('Nothing is published when nothing subscribes', async() => { + const published = [] + const restore = [] + const names = ['query', 'connection', 'transaction'].flatMap(name => stages.map(stage => 'tracing:postgres:' + name + ':' + stage)) + .concat('postgres:query:send', 'postgres:connection:close') + for (const name of names) { + const channel = dc.channel(name) + for (const method of ['publish', 'runStores']) { + const original = channel[method] + channel[method] = function() { + published.push(name + '.' + method) + return original.apply(this, arguments) + } + restore.push(() => delete channel[method]) + } + } + + const sql = postgres(options) + try { + await sql`select 1 as x` + await sql.begin(async sql => { + await sql`select 2 as x` + await sql.savepoint(sql => sql`select 3 as x`) + }) + } finally { + await sql.end() + restore.forEach(x => x()) + } + + return [JSON.stringify([]), JSON.stringify(published)] +}) From a35c1a5c414cfa53f7db9358e05b3dd834859a75 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:09:10 +0100 Subject: [PATCH 03/34] docs: describe the tracing channels Adds the Tracing channels section to the API reference with the channel list, the order of events, when each context field is set and a subscriber example, and a line each in the README feature lists. --- README.md | 2 ++ docs/api.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/README.md b/README.md index 50dca684..5b637775 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - **Correctness fixes.** Queries get the right results, transactions stay isolated, and rollbacks are reported as failures. - **Fewer hangs and crashes.** Fixes for stuck queries, shutdown hangs and crashes after disconnects. - **Pool monitoring.** See busy connections, idle connections and waiting queries with `sql.stats()`. +- **Tracing without patches.** Queries, connections and transactions publish [`diagnostics_channel` events](docs/api.md#tracing-channels) for APM and OpenTelemetry subscribers, and nothing is built while no one subscribes. - **Faster queries behind poolers.** Removes an extra network round trip for supported values (opt-in). - **Vercel Fluid Compute support.** Reuse connections between requests and close idle ones before suspension to avoid connection leaks. - **Cloudflare Workers on the cheapest path.** Connects through Cloudflare's own `cloudflare:sockets` API, which costs less CPU per connection than workerd's `node:net`, tested locally and on Cloudflare's network with Neon, Supabase and Hyperdrive. @@ -115,6 +116,7 @@ With `prepare: false`, parameterized queries can pay for an extra network round ### New features - **See pool pressure.** [`sql.stats()`](docs/api.md#pool-stats) reports busy and idle connections and waiting queries. +- **Trace queries natively.** [`postgres:query`, `postgres:connection` and `postgres:transaction`](docs/api.md#tracing-channels) are `diagnostics_channel` tracing channels, published only when something subscribes. - **Handle serverless suspension.** Expired idle connections are replaced on resume; the [Vercel Fluid Compute adapter](docs/api.md#vercel-fluid-compute) keeps instances alive until idle connections close. - **Automatic cleanup.** [`await using`](docs/api.md#teardown--cleanup) closes a pool and `using` releases a reserved connection when its scope ends. Requires Node.js 24+ or TypeScript 5.2+. - **Cloudflare Workers fixes.** On [Workers](docs/api.md#cloudflare-workers-support), `COPY TO` streams and `subscribe()` pause and resume the socket ([#975](https://github.com/porsager/postgres/issues/975)), `ssl: 'prefer'` falls back to plaintext when the server declines TLS, and a certificate from a private CA fails at once with [`TLS_CLOSED`](docs/api.md#tls_closed) instead of a timeout ([#1213](https://github.com/porsager/postgres/issues/1213)). diff --git a/docs/api.md b/docs/api.md index f1a3da73..013ef624 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,6 +19,7 @@ The full API of [@steadybrew/postgresjs](../README.md). Installing, switching fr * [Custom Types](#custom-types) * [Teardown / Cleanup](#teardown--cleanup) * [Reserving connections](#reserving-connections) +* [Tracing channels](#tracing-channels) * [Error handling](#error-handling) * [TypeScript support](#typescript-support) * [Migration tools](#migration-tools) @@ -1509,6 +1510,64 @@ await reserved`select * from users` After release, queries through that reserved handle reject with [`CONNECTION_RELEASED`](#connection_released). Call `sql.reserve()` again to acquire a new handle. A reserved handle whose connection has closed rejects with `CONNECTION_CLOSED` until it is released, and cannot be reused after reconnect. +## Tracing channels + +The driver publishes `diagnostics_channel` events for queries, connections and transactions, so an APM or OpenTelemetry integration can subscribe without patching the driver. Nothing is built or published while no one subscribes: a query costs one `hasSubscribers` check. + +| Channel | Type | Covers | +|---|---|---| +| `postgres:query` | `TracingChannel` | One span per query, from the call that runs it until the server answers or the query fails. | +| `postgres:connection` | `TracingChannel` | One span per connection acquisition: socket, TLS, authentication, startup and type fetching, up to the connection serving queries. Retries and other hosts stay inside the same span. | +| `postgres:transaction` | `TracingChannel` | `sql.begin()` and, nested in it, each `sql.savepoint()`. | +| `postgres:query:send` | `Channel` | The query's context again, each time it is written to a connection. | +| `postgres:connection:close` | `Channel` | `{ connectionId, error }` whenever a connection closes, including a failed connection attempt. `error` is undefined for a normal close. | + +The `TracingChannel`s publish in the order Node's `tracePromise` does: `start` and `end` in the caller's synchronous code, then for a failure `error`, and for both outcomes `asyncStart` and `asyncEnd`, with `context.result` or `context.error` set first. `start` is published with `runStores`, so a store bound to it, such as an `AsyncLocalStorage` span context, is current in the code that starts the query. Every query publishes `asyncStart` and `asyncEnd` exactly once. That includes cursors read with `for await` or a callback, `forEach`, `COPY` streams (once the copy is over), `describe()`, `simple()`, `unsafe()`, `file()`, pipelined queries, retried queries and queries cancelled before they were sent. The driver's own startup queries (type fetching, `target_session_attrs`) are not published; they belong to the connection span. + +The context objects are the `QueryTraceContext`, `ConnectionTraceContext` and `TransactionTraceContext` types. + +| Field | Channel | Set | +|---|---|---| +| `database`, `user` | all three | at `start` | +| `serverAddress`, `serverPort` | query, connection | at `start` when one host is configured; otherwise when the query is written, or when each connection attempt opens its socket. A Unix socket path is the address, with no port. | +| `query` | query | when the query is written: SQL text with `$n` placeholders | +| `values` | query | when the query is written: the parameters before they are serialized, raw | +| `connectionId` | query, connection | when the query is written (query); at `start` (connection). It is the `id` that `onclose` and `onidle` report. | +| `result` | query | before `asyncStart`: the `Result` with `command` and `count`; for `describe()` the statement; for a `COPY` stream the `Result` of the finished copy, not the stream | +| `result` | transaction | before `asyncStart` for `sql.begin()`: `{ committed }`, false when the transaction was prepared | +| `error` | all three | before `error` | +| `options` | transaction | at `start`: the mode string given to `sql.begin()` | +| `savepoint` | transaction | at `start` for `sql.savepoint()`: the savepoint name as sent, such as `s0_name` | +| `prepared` | transaction | when `sql.prepare(name)` is called | + +The text and parameters are known only after the driver has serialized the query for a connection, so they are not in the context at `start`. Read them at `asyncEnd` or `error`, or rename the span then. A query written again after a retry publishes `postgres:query:send` again, with the same context. The time between `start` and the first `postgres:query:send` is the wait for a connection, and between that and `asyncEnd` the time on the server. + +Parameters are included as they are. Decide in your subscriber what to record, since values can hold personal data. + +```js +import dc from 'node:diagnostics_channel' + +const channel = dc.tracingChannel('postgres:query') + +channel.subscribe({ + start(context) { + context.startedAt = performance.now() + }, + end() {}, + asyncStart() {}, + asyncEnd(context) { + console.log(context.query, context.result.count, performance.now() - context.startedAt) + }, + error(context) { + console.error(context.query, context.error) + } +}) +``` + +To make a span current for the code that runs the query, bind an `AsyncLocalStorage` to the start channel with `channel.start.bindStore(storage, context => span)`. + +On Node.js the channels need Node 22 or newer, as the package does. On a runtime without `diagnostics_channel.tracingChannel` nothing is published. On Cloudflare Workers the module needs the `nodejs_compat` flag, which the driver requires already. + ## Error handling Errors are all thrown to related queries and never globally. Errors coming from database itself are always in the [native Postgres format](https://www.postgresql.org/docs/current/errcodes-appendix.html), and the same goes for any [Node.js errors](https://nodejs.org/api/errors.html#errors_common_system_errors) eg. coming from the underlying connection. From 2632914eb13c2b7c17772e7acd7bd6b60f93ac2c Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:09:20 +0100 Subject: [PATCH 04/34] docs: refresh the diff since 3.4.9 --- docs/changes-since-3.4.9.diff | 490 ++++++++++++++++++++++++++-------- 1 file changed, 380 insertions(+), 110 deletions(-) diff --git a/docs/changes-since-3.4.9.diff b/docs/changes-since-3.4.9.diff index 3c8f9a75..0c8c5687 100644 --- a/docs/changes-since-3.4.9.diff +++ b/docs/changes-since-3.4.9.diff @@ -1,8 +1,8 @@ diff --git a/src/connection.js b/src/connection.js -index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f47b7e2bf6 100644 +index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..5923e6cf00e74d4474ca38ad37fc05b51a18c6e6 100644 --- a/src/connection.js +++ b/src/connection.js -@@ -4,24 +4,41 @@ import crypto from 'crypto' +@@ -4,24 +4,42 @@ import crypto from 'crypto' import Stream from 'stream' import { performance } from 'perf_hooks' @@ -16,6 +16,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 +import { SSLRequest, tlsConfig, cancelRequest } from './transport.js' import { Query, CLOSE } from './query.js' import b from './bytes.js' ++import { connectionChannel, watched, run, resolved, rejected, serverOf, querySent, connectionClosed } from './tracing.js' -export default Connection +export { Connection } @@ -48,7 +49,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 const retryRoutines = new Set([ 'FetchPreparedStatement', 'RevalidateCachedQuery', -@@ -49,7 +66,7 @@ const errorFields = { +@@ -49,7 +67,7 @@ const errorFields = { 82 : 'routine' // R } @@ -57,7 +58,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 const { sslnegotiation, ssl, -@@ -72,12 +89,20 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -72,12 +90,20 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose const sent = Queue() , id = uid++ , backend = { pid: null, secret: null } @@ -82,7 +83,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 , errorResponse = null , result = new Result() , incoming = Buffer.alloc(0) -@@ -86,82 +111,442 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -86,82 +112,464 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose , statements = {} , statementId = Math.random().toString(36).slice(2) , statementCount = 1 @@ -90,6 +91,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 , remaining = 0 , hostIndex = 0 - , retries = 0 ++ , serverIndex = 0 , length = 0 - , delay = 0 , rows = 0 @@ -298,12 +300,28 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 + return backoffSeconds(backoff, options.shared.retries) * 1000 + } + ++ function serverAddress() { ++ return options.path || host[serverIndex] ++ } ++ ++ function serverPort() { ++ return options.path ? undefined : port[serverIndex] ++ } ++ + function standbyPass() { + return target_session_attrs === 'prefer-standby' && host.length > 1 && acquisition.pass === 'standby' + } + + function acquire(owner) { -+ acquisition = { owner, ending: false, pass: 'standby', hostsTried: 0, attempting: null, mismatches: [], lastError: null } ++ acquisition = { owner, ending: false, pass: 'standby', hostsTried: 0, attempting: null, mismatches: [], lastError: null, trace: undefined } ++ if (!watched(connectionChannel)) ++ return startAcquisition() ++ ++ acquisition.trace = { connectionId: id, database, user, ...serverOf(options) } ++ run(connectionChannel, acquisition.trace, startAcquisition) ++ } ++ ++ function startAcquisition() { + const wait = inheritedBackoff ? inheritedBackoff.at + inheritedBackoff.delay - performance.now() : 0 + inheritedBackoff = null + wait > 0 ? enterBackoff(wait) : enterConnecting() @@ -346,6 +364,8 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 + function attach(created) { + listen(created) + socket = created ++ serverIndex = hostIndex ++ acquisition.trace && (acquisition.trace.serverAddress = serverAddress(), acquisition.trace.serverPort = serverPort()) + acquisition.attempting = options.socket ? 'custom socket' : options.path || host[hostIndex] + ':' + port[hostIndex] + + if (options.socket) @@ -448,7 +468,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 + + delay = backoffMs() + } -+ } + } + + clearTimeout(attemptTimer) + attemptTimer = null @@ -495,8 +515,10 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 + acquisition = null + endWaiters = [] + transition(Phase.Closed) ++ a && a.trace && rejected(connectionChannel, a.trace, err) + a && queryError(a.owner, err) + waiters.forEach(resolve => resolve()) ++ connectionClosed(id, reported) + onclose(connection, reported) + } + @@ -519,14 +541,15 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 + ms && (closeTimer = clampedTimeout(() => (closeTimer = null, dispatch('closeTimeout')), ms)) + } else { + socket.destroy() - } - } - ++ } ++ } ++ + function handoff() { -+ const { owner, ending } = acquisition ++ const { owner, ending, trace } = acquisition + transition(Phase.Ready) + clearAcquisitionTimers() + acquisition = null ++ trace && resolved(connectionChannel, trace) + options.shared.retries = 0 + lifeTimer.start() + @@ -538,8 +561,8 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 + } + + onopen(connection, owner.cancelled ? undefined : owner) -+ } -+ + } + function execute(q) { - if (terminated) - return queryError(q, Errors.connection('CONNECTION_DESTROYED', options)) @@ -563,7 +586,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 try { q.state = backend -@@ -169,19 +554,35 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -169,19 +577,35 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose ? sent.push(q) : (query = q, query.active = true) @@ -607,7 +630,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 function toBuffer(q) { if (q.parameters.length >= 65534) throw Errors.generic('MAX_PARAMETERS_EXCEEDED', 'Max number of parameters (65534) exceeded') -@@ -206,7 +607,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -206,7 +630,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose function prepared(q) { return Buffer.concat([ @@ -616,7 +639,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 q.cursorFn ? Execute('', q.cursorRows) : ExecuteUnnamed -@@ -234,13 +635,16 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -234,13 +658,17 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose q.signature = q.prepare && types + string q.onlyDescribe && (delete statements[q.signature]) q.parameters = q.parameters || parameters @@ -632,10 +655,11 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 - typeof options.debug === 'function' && options.debug(id, string, parameters, types) + typeof options.debug === 'function' && callback(options.debug, id, string, parameters, types) ++ q.trace && querySent(q, id, serverAddress(), serverPort()) } function write(x, fn) { -@@ -252,52 +656,12 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -252,52 +680,12 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function nextWrite(fn) { @@ -689,7 +713,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 function data(x) { if (incomings) { incomings.push(x) -@@ -312,6 +676,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -312,6 +700,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose ? x : Buffer.concat([incoming, x], incoming.length + x.length) @@ -697,7 +721,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 while (incoming.length > 4) { length = incoming.readUInt32BE(1) if (length >= incoming.length) { -@@ -323,74 +688,34 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -323,74 +712,34 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose try { handle(incoming.subarray(0, length + 1)) } catch (e) { @@ -744,12 +768,8 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 - - function reconnect() { - setTimeout(connect, closedTime ? Math.max(0, closedTime + delay - performance.now()) : 0) -+ function lost(err, counted = true) { -+ counted && options.shared.retries++ -+ inheritedBackoff = { at: performance.now(), delay: backoffMs() } -+ enterClosed(err, err.code === 'CONNECTION_CLOSED' ? err : closedBy(err)) - } - +- } +- - function connected() { - try { - statements = {} @@ -764,8 +784,12 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 - } catch (err) { - error(err) - } -- } -- ++ function lost(err, counted = true) { ++ counted && options.shared.retries++ ++ inheritedBackoff = { at: performance.now(), delay: backoffMs() } ++ enterClosed(err, err.code === 'CONNECTION_CLOSED' ? err : closedBy(err)) + } + - function error(err) { - if (connection.queue === queues.connecting && options.host[retries + 1]) - return @@ -786,7 +810,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 } function queryError(query, err) { -@@ -401,7 +726,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -401,7 +750,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose err = new Error(err) 'query' in err || 'parameters' in err || Object.defineProperties(err, { @@ -795,7 +819,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 query: { value: query.string, enumerable: options.debug }, parameters: { value: query.parameters, enumerable: options.debug }, args: { value: query.args, enumerable: options.debug }, -@@ -410,51 +735,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -410,51 +759,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose query.reject(err) } @@ -855,7 +879,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 } /* Handlers */ -@@ -495,6 +783,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -495,6 +807,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose let column let value @@ -864,7 +888,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 const row = query.isRaw ? new Array(query.statement.columns.length) : {} for (let i = 0; i < query.statement.columns.length; i++) { column = query.statement.columns[i] -@@ -514,13 +804,13 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -514,13 +828,13 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose query.isRaw ? (row[i] = query.isRaw === true ? value @@ -882,7 +906,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 } function ParameterStatus(x) { -@@ -528,19 +818,25 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -528,19 +842,25 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose backendParameters[k] = v if (options.parameters[k] !== v) { options.parameters[k] = v @@ -910,7 +934,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 query.resolve(results || result) } } else if (errorResponse) { -@@ -549,42 +845,34 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -549,42 +869,34 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose query = results = errorResponse = null result = new Result() @@ -947,9 +971,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 - Connection(options).cancel(query.state, query.cancelled.resolve, query.cancelled.reject) + return query + } - -- if (query) -- return // Consider opening if able and sent.length < 50 ++ + function initialized() { + if (target_session_attrs) { + if (!backendParameters.in_hot_standby || !backendParameters.default_transaction_read_only) @@ -958,7 +980,9 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 + if (reason) + return mismatched(reason) + } -+ + +- if (query) +- return // Consider opening if able and sent.length < 50 + if (needsTypes) + return fetchArrayTypes() @@ -975,7 +999,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 } function CommandComplete(x) { -@@ -602,7 +890,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -602,7 +914,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose final && (final(), final = null) @@ -984,7 +1008,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 return errored(Errors.generic('UNSAFE_TRANSACTION', 'Only use sql.begin, sql.reserved or max: 1')) if (query.options.simple) -@@ -654,7 +942,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -654,7 +966,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose const number = x.readUInt16BE(index + 4) const type = x.readUInt32BE(index + 6) query.statement.columns[i] = { @@ -993,7 +1017,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 ? transform.column.from(x.toString('utf8', start, index - 1)) : x.toString('utf8', start, index - 1), parser: parsers[type], -@@ -670,8 +958,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -670,8 +982,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose return (query.resolve(query.statement), write(Sync)) } @@ -1005,7 +1029,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 type === 3 ? AuthenticationCleartextPassword : type === 5 ? AuthenticationMD5Password : type === 10 ? SASL : -@@ -679,18 +968,20 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -679,18 +992,20 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose type === 12 ? SASLFinal : type !== 0 ? UnknownAuth : noop @@ -1029,7 +1053,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 const payload = 'md5' + ( await md5( Buffer.concat([ -@@ -699,40 +990,42 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -699,40 +1014,42 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose ]) ) ) @@ -1063,10 +1087,10 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 - parseInt(res.i), 32, - 'sha256' - ) +- +- const clientKey = await hmac(saltedPassword, 'Client Key') + const { clientKey, storedKey, serverKey } = await scramKeys(await Pass(), res.s, res.i) -- const clientKey = await hmac(saltedPassword, 'Client Key') -- - const auth = 'n=*,r=' + nonce + ',' + const auth = 'n=*,r=' + clientNonce + ',' + 'r=' + res.r + ',s=' + res.s + ',i=' + res.i @@ -1086,7 +1110,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 write( b().p().str(payload).end() ) -@@ -742,8 +1035,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -742,8 +1059,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose if (x.toString('utf8', 9).split(b.N, 1)[0].slice(2) === serverSignature) return /* c8 ignore next 5 */ @@ -1096,7 +1120,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 } function Pass() { -@@ -765,48 +1057,47 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -765,48 +1081,47 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose backend.secret = x.readUInt32BE(9) } @@ -1113,8 +1137,9 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 order by b.oid - `], [], execute) - types.forEach(({ oid, typarray }) => addArrayType(oid, typarray)) -- } -- ++ `, types => types.forEach(({ oid, typarray }) => addArrayType(options, oid, typarray))) + } + - function addArrayType(oid, typarray) { - if (!!options.parsers[typarray] && !!options.serializers[typarray]) return - const parser = options.parsers[oid] @@ -1122,9 +1147,8 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 - options.parsers[typarray] = (xs) => arrayParser(xs, parser, typarray) - options.parsers[typarray].array = true - options.serializers[typarray] = (xs) => arraySerializer(xs, options.serializers[oid], options, typarray) -+ `, types => types.forEach(({ oid, typarray }) => addArrayType(options, oid, typarray))) - } - +- } +- - function tryNext(x, xs) { + function mismatchReason(x, xs) { return ( @@ -1168,7 +1192,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 } function ErrorResponse(x) { -@@ -814,12 +1105,11 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -814,12 +1129,11 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose (query.cursorFn || query.describeFirst) && write(Sync) errorResponse = Errors.postgres(parseError(x)) } else { @@ -1182,7 +1206,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 q.retried = error execute(q) } -@@ -830,22 +1120,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -830,22 +1144,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose let index = 9 while (x[index++] !== 0); @@ -1216,7 +1240,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 } } -@@ -862,7 +1157,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -862,7 +1181,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose }, destroy(error, callback) { callback(error) @@ -1225,7 +1249,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 stream = null }, final(callback) { -@@ -872,27 +1167,29 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -872,27 +1191,29 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } }) query.resolve(stream) @@ -1258,7 +1282,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 stream = null }, final(callback) { -@@ -901,22 +1198,36 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -901,22 +1222,36 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } }) query.resolve(stream) @@ -1298,7 +1322,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 } /* c8 ignore next 3 */ -@@ -945,20 +1256,21 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -945,20 +1280,21 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } /* Messages */ @@ -1327,7 +1351,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 prev = b.i b.inc(4).str(x).i32(b.i - prev - 4, prev) -@@ -966,7 +1278,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -966,7 +1302,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose b.i16(0) @@ -1356,7 +1380,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 } function Parse(str, parameters, types, name = '') { -@@ -994,7 +1326,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -994,7 +1350,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function StartupMessage() { @@ -1365,7 +1389,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 Object.entries(Object.assign({ user, database, -@@ -1019,6 +1351,33 @@ function parseError(x) { +@@ -1019,6 +1375,33 @@ function parseError(x) { return error } @@ -1399,7 +1423,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..4e05655b08eb1c8981ba627f49d650f4 function md5(x) { return crypto.createHash('md5').update(x).digest('hex') } -@@ -1042,21 +1401,44 @@ function xor(a, b) { +@@ -1042,21 +1425,44 @@ function xor(a, b) { function timer(fn, seconds) { seconds = typeof seconds === 'function' ? seconds() : seconds if (!seconds) @@ -1485,7 +1509,7 @@ index 0ff83c428bec774e11417b8e1f033dc3305c18cf..023c3ba1bd113e6f5508ab079efcf220 code: x, errno: x, diff --git a/src/index.js b/src/index.js -index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd02329497fd7 100644 +index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0ac788951 100644 --- a/src/index.js +++ b/src/index.js @@ -3,10 +3,12 @@ import fs from 'fs' @@ -1501,7 +1525,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 toPascal, pascal, toCamel, -@@ -18,13 +20,17 @@ import { +@@ -18,12 +20,17 @@ import { fromKebab } from './types.js' @@ -1515,13 +1539,13 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 +import { Errors, PostgresError, callback } from './errors.js' import Subscribe from './subscribe.js' import largeObject from './large.js' - -+const Lease = { Active: 0, Settling: 1, Released: 2, Ended: 3, Closed: 4 } ++import { transactionChannel, watched, traceAsync } from './tracing.js' + ++const Lease = { Active: 0, Settling: 1, Released: 2, Ended: 3, Closed: 4 } + Object.assign(Postgres, { PostgresError, - toPascal, -@@ -51,6 +57,7 @@ function Postgres(a, b) { +@@ -51,6 +58,7 @@ function Postgres(a, b) { , subscribe = options.no_subscribe || Subscribe(Postgres, { ...options }) let ending = false @@ -1529,7 +1553,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 const queries = Queue() , connecting = Queue() -@@ -62,7 +69,7 @@ function Postgres(a, b) { +@@ -62,7 +70,7 @@ function Postgres(a, b) { , full = Queue() , queues = { connecting, reserved, closed, ended, open, busy, full } @@ -1538,7 +1562,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 const sql = Sql(handler) -@@ -75,10 +82,12 @@ function Postgres(a, b) { +@@ -75,16 +83,19 @@ function Postgres(a, b) { PostgresError, options, reserve, @@ -1552,7 +1576,34 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 }) return sql -@@ -144,6 +153,56 @@ function Postgres(a, b) { + + function Sql(handler) { + handler.debug = options.debug ++ handler.config = options + + Object.entries(options.types).reduce((acc, [name, type]) => { + acc[name] = (x) => new Parameter(x, type.to) +@@ -128,7 +139,7 @@ function Postgres(a, b) { + + function file(path, args = [], options = {}) { + arguments.length === 2 && !Array.isArray(args) && (options = args, args = []) +- const query = new Query([], args, (query) => { ++ const read = query => { + fs.readFile(path, 'utf8', (err, string) => { + if (err) + return query.reject(err) +@@ -136,7 +147,9 @@ function Postgres(a, b) { + query.strings = [string] + handler(query) + }) +- }, cancel, { ++ } ++ read.config = handler.config ++ const query = new Query([], args, read, cancel, { + ...options, + simple: 'simple' in options ? options.simple : args.length === 0 + }) +@@ -144,6 +157,56 @@ function Postgres(a, b) { } } @@ -1609,7 +1660,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 async function listen(name, fn, onlisten) { const listener = { fn, onlisten } -@@ -153,6 +212,7 @@ function Postgres(a, b) { +@@ -153,6 +216,7 @@ function Postgres(a, b) { idle_timeout: null, max_lifetime: null, fetch_types: false, @@ -1617,7 +1668,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 onclose() { Object.entries(listen.channels).forEach(([name, { listeners }]) => { delete listen.channels[name] -@@ -160,7 +220,7 @@ function Postgres(a, b) { +@@ -160,7 +224,7 @@ function Postgres(a, b) { }) }, onnotify(c, x) { @@ -1626,7 +1677,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 } })) -@@ -170,7 +230,7 @@ function Postgres(a, b) { +@@ -170,7 +234,7 @@ function Postgres(a, b) { if (exists) { channels[name].listeners.push(listener) const result = await channels[name].result @@ -1635,7 +1686,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 return { state: result.state, unlisten } } -@@ -178,7 +238,7 @@ function Postgres(a, b) { +@@ -178,7 +242,7 @@ function Postgres(a, b) { sql.unsafe('"' + name.replace(/"/g, '""') + '"') }`, listeners: [listener] } const result = await channels[name].result @@ -1644,7 +1695,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 return { state: result.state, unlisten } async function unlisten() { -@@ -200,62 +260,146 @@ function Postgres(a, b) { +@@ -200,62 +264,159 @@ function Postgres(a, b) { return await sql`select pg_notify(${ channel }, ${ '' + payload })` } @@ -1708,20 +1759,20 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 + ? Errors.generic('CONNECTION_RELEASED', 'Reserved connection used after release()') + : Errors.generic('TRANSACTION_ENDED', 'Transaction handle used after its transaction ended') + } ++ ++ function stop(l, state) { ++ if (l.state === Lease.Released || l.state === Lease.Ended) ++ return null - move(c, reserved) - c.reserved = () => queue.length - ? c.execute(queue.shift()) - : move(c, reserved) - c.reserved.release = true -+ function stop(l, state) { -+ if (l.state === Lease.Released || l.state === Lease.Ended) -+ return null - + l.state = state + while (l.queue.length) + l.queue.shift().reject(leaseError(l)) -+ + + const c = l.connection + if (c.owner !== l) + return null @@ -1754,9 +1805,23 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 } } - async function begin(options, fn) { - !fn && (fn = options, options = '') +- async function begin(options, fn) { +- !fn && (fn = options, options = '') - const queries = Queue() ++ function begin(mode, fn) { ++ !fn && (fn = mode, mode = '') ++ if (!watched(transactionChannel)) ++ return transact(mode, fn) ++ ++ const trace = transactionTrace(mode) ++ return traceAsync(transactionChannel, trace, () => transact(mode, fn, trace)) ++ } ++ ++ function transactionTrace(mode, savepoint) { ++ return { database: options.database, user: options.user, options: mode, savepoint, prepared: undefined, result: undefined } ++ } ++ ++ async function transact(options, fn, trace) { + const l = await lease(true) let savepoints = 0 - , connection @@ -1780,10 +1845,8 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 + throw e + }) + return scope(fn) - } - -- async function scope(c, fn, name) { -- const sql = Sql(handler) ++ } ++ + async function finish(statement) { + let result + l.state = Lease.Settling @@ -1800,8 +1863,10 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 + function release() { + const c = stop(l, Lease.Ended) + c && c.release() -+ } -+ + } + +- async function scope(c, fn, name) { +- const sql = Sql(handler) + function terminate() { + const c = stop(l, Lease.Ended) + c && c.terminate() @@ -1816,6 +1881,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 + throw Errors.generic('INVALID_TRANSACTION_NAME', 'sql.prepare requires a non-empty string without null bytes') + + prepare = 'E\'' + x.replace(/\\/g, '\\\\').replace(/'/g, '\'\'') + '\'' ++ trace && (trace.prepared = x) + } let uncaughtError , result @@ -1825,7 +1891,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 try { result = await new Promise((resolve, reject) => { const x = fn(sql) -@@ -265,17 +409,27 @@ function Postgres(a, b) { +@@ -265,17 +426,29 @@ function Postgres(a, b) { if (uncaughtError) throw uncaughtError } catch (e) { @@ -1856,15 +1922,20 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 + uncaughtError && { cause: uncaughtError } + ) + } ++ ++ trace && (trace.result = { committed: prepare === null }) } return result -@@ -285,23 +439,18 @@ function Postgres(a, b) { +@@ -285,23 +458,21 @@ function Postgres(a, b) { return savepoint(sql => sql.apply(sql, arguments)) arguments.length === 1 && (fn = name, name = null) - return scope(c, fn, 's' + savepoints++ + (name ? '_' + name : '')) -+ return scope(fn, 's' + savepoints++ + (name ? '_' + name : '')) ++ const id = 's' + savepoints++ + (name ? '_' + name : '') ++ return watched(transactionChannel) ++ ? traceAsync(transactionChannel, transactionTrace(undefined, id), () => scope(fn, id)) ++ : scope(fn, id) } - function handler(q) { @@ -1889,7 +1960,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 } } -@@ -310,7 +459,7 @@ function Postgres(a, b) { +@@ -310,7 +481,7 @@ function Postgres(a, b) { queue.push(c) c.queue = queue queue === open @@ -1898,7 +1969,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 : c.idleTimer.cancel() return c } -@@ -330,15 +479,51 @@ function Postgres(a, b) { +@@ -330,15 +501,51 @@ function Postgres(a, b) { if (ending) return query.reject(Errors.connection('CONNECTION_ENDED', options, options)) @@ -1955,7 +2026,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 } function go(c, query) { -@@ -348,17 +533,18 @@ function Postgres(a, b) { +@@ -348,17 +555,18 @@ function Postgres(a, b) { } function cancel(query) { @@ -1984,7 +2055,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 }) } -@@ -368,13 +554,23 @@ function Postgres(a, b) { +@@ -368,13 +576,23 @@ function Postgres(a, b) { await 1 let timer @@ -2015,7 +2086,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 } async function close() { -@@ -382,9 +578,9 @@ function Postgres(a, b) { +@@ -382,9 +600,9 @@ function Postgres(a, b) { } async function destroy(resolve) { @@ -2026,7 +2097,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 resolve() } -@@ -398,12 +594,18 @@ function Postgres(a, b) { +@@ -398,12 +616,18 @@ function Postgres(a, b) { move(c, ended) } @@ -2048,7 +2119,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 while (ready && queries.length && max-- > 0) { const query = queries.shift() -@@ -418,12 +620,60 @@ function Postgres(a, b) { +@@ -418,12 +642,60 @@ function Postgres(a, b) { : move(c, full) } @@ -2113,7 +2184,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 } } -@@ -453,16 +703,17 @@ function parseOptions(a, b) { +@@ -453,16 +725,17 @@ function parseOptions(a, b) { connect_timeout : 30, max_lifetime : max_lifetime, max_pipeline : 100, @@ -2133,7 +2204,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 host : Array.isArray(host) ? host : host.split(',').map(x => x.split(':')[0]), port : Array.isArray(port) ? port : host.split(',').map(x => parseInt(x.split(':')[1] || port)), path : o.path || host.indexOf('/') > -1 && host + '/.s.PGSQL.' + port, -@@ -472,8 +723,8 @@ function parseOptions(a, b) { +@@ -472,8 +745,8 @@ function parseOptions(a, b) { ...Object.entries(defaults).reduce( (acc, [k, d]) => { const value = k in o ? o[k] : k in query @@ -2144,7 +2215,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 acc[k] = typeof value === 'string' && ints.includes(k) ? +value : value -@@ -488,16 +739,26 @@ function parseOptions(a, b) { +@@ -488,16 +761,26 @@ function parseOptions(a, b) { }, types : o.types || {}, target_session_attrs: tsa(o, url, env), @@ -2172,7 +2243,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 } function tsa(o, url, env) { -@@ -508,8 +769,12 @@ function tsa(o, url, env) { +@@ -508,8 +791,12 @@ function tsa(o, url, env) { throw new Error('target_session_attrs ' + x + ' is not supported') } @@ -2188,25 +2259,34 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..db93ccaa423e3162f42f99c9e52cd023 function max_lifetime() { diff --git a/src/query.js b/src/query.js -index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..c1777a904c2f214a041fb8af0b56f0ac38418081 100644 +index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..b7bd3e66c5f16349d822427509720cb38af8ebca 100644 --- a/src/query.js +++ b/src/query.js -@@ -23,10 +23,12 @@ export class Query extends Promise { +@@ -1,3 +1,5 @@ ++import { queryChannel, watched, traceQuery, querySettled, queryFailed } from './tracing.js' ++ + const originCache = new Map() + , originStackCache = new Map() + , originError = Symbol('OriginError') +@@ -23,12 +25,15 @@ export class Query extends Promise { this.state = null this.statement = null - this.resolve = x => (this.active = false, resolve(x)) - this.reject = x => (this.active = false, reject(x)) + // dd-trace's load-time rewrite only accepts single-expression arrows here -+ this.resolve = x => (this.active = false, this.settled = true, resolve(x)) -+ this.reject = x => (this.active = false, this.settled = true, reject(x)) ++ this.resolve = x => (this.trace && querySettled(this, x), this.active = false, this.settled = true, resolve(x)) ++ this.reject = x => (this.trace && queryFailed(this, x), this.active = false, this.settled = true, reject(x)) this.active = false + this.settled = false this.cancelled = null this.executed = false ++ this.trace = undefined this.signature = '' -@@ -50,7 +52,14 @@ export class Query extends Promise { + + this[originError] = this.handler.debug +@@ -50,7 +55,14 @@ export class Query extends Promise { } cancel() { @@ -2222,6 +2302,46 @@ index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..c1777a904c2f214a041fb8af0b56f0ac } simple() { +@@ -96,8 +108,8 @@ export class Query extends Promise { + resolve({ value, done: false }) + return new Promise(r => prev = r) + } +- this.resolve = () => (this.active = false, resolve({ done: true })) +- this.reject = x => (this.active = false, reject(x)) ++ this.resolve = x => (this.trace && querySettled(this, x), this.active = false, resolve({ done: true })) ++ this.reject = x => (this.trace && queryFailed(this, x), this.active = false, reject(x)) + }) + this.execute() + return promise +@@ -137,7 +149,15 @@ export class Query extends Promise { + } + + async handle() { +- !this.executed && (this.executed = true) && await 1 && this.handler(this) ++ if (this.executed) ++ return ++ ++ this.executed = true ++ if (!this.initialization && watched(queryChannel)) ++ return traceQuery(this, dispatch) ++ ++ await 1 ++ this.handler(this) + } + + execute() { +@@ -161,6 +181,11 @@ export class Query extends Promise { + } + } + ++async function dispatch(q) { ++ await 1 ++ q.handler(q) ++} ++ + function cachedError(xs) { + if (originCache.has(xs)) + return originCache.get(xs) diff --git a/src/subscribe.js b/src/subscribe.js index 4f8934cc8277fc58d2c37838cfb1afc315bc0744..0fd5d4201123e4c08d85fd1c46396baa60ba53cf 100644 --- a/src/subscribe.js @@ -2536,6 +2656,105 @@ index 0000000000000000000000000000000000000000..bcdf4c1894e0f84957d7ff3890bea9d8 +export default function clampedTimeout(fn, ms) { + return setTimeout(fn, Math.min(ms, maxDelay)) +} +diff --git a/src/tracing.js b/src/tracing.js +new file mode 100644 +index 0000000000000000000000000000000000000000..141a8bf28946f2154758bb539fec2b51dacc8b1d +--- /dev/null ++++ b/src/tracing.js +@@ -0,0 +1,93 @@ ++import dc from 'diagnostics_channel' ++ ++const supported = Boolean(dc) && typeof dc.tracingChannel === 'function' ++ ++export const queryChannel = supported ? dc.tracingChannel('postgres:query') : undefined ++export const connectionChannel = supported ? dc.tracingChannel('postgres:connection') : undefined ++export const transactionChannel = supported ? dc.tracingChannel('postgres:transaction') : undefined ++export const sendChannel = supported ? dc.channel('postgres:query:send') : undefined ++export const closeChannel = supported ? dc.channel('postgres:connection:close') : undefined ++ ++export function watched(channel) { ++ return channel !== undefined && (channel.hasSubscribers ?? channel.start.hasSubscribers) ++} ++ ++export function run(channel, context, fn, ...args) { ++ try { ++ return channel.start.runStores(context, fn, undefined, ...args) ++ } finally { ++ channel.end.publish(context) ++ } ++} ++ ++export function resolved(channel, context) { ++ channel.asyncStart.publish(context) ++ channel.asyncEnd.publish(context) ++} ++ ++export function rejected(channel, context, error) { ++ context.error = error ++ channel.error.publish(context) ++ resolved(channel, context) ++} ++ ++export function traceAsync(channel, context, fn) { ++ return run(channel, context, fn).then( ++ result => (resolved(channel, context), result), ++ error => { ++ rejected(channel, context, error) ++ throw error ++ } ++ ) ++} ++ ++export function serverOf({ host, port, path }) { ++ return path ++ ? { serverAddress: path, serverPort: undefined } ++ : host.length === 1 ++ ? { serverAddress: host[0], serverPort: port[0] } ++ : { serverAddress: undefined, serverPort: undefined } ++} ++ ++export function traceQuery(q, fn) { ++ const config = q.handler.config ++ q.trace = { ++ query: undefined, ++ values: undefined, ++ database: config && config.database, ++ user: config && config.user, ++ ...config && serverOf(config), ++ connectionId: undefined ++ } ++ return run(queryChannel, q.trace, fn, q) ++} ++ ++export function querySent(q, connectionId, serverAddress, serverPort) { ++ const context = q.trace ++ context.query = q.string ++ context.values = q.parameters ++ context.connectionId = connectionId ++ context.serverAddress = serverAddress ++ context.serverPort = serverPort ++ sendChannel.hasSubscribers && sendChannel.publish(context) ++} ++ ++export function querySettled(q, result) { ++ if (q.streaming && result && typeof result.pipe === 'function') ++ return ++ ++ const context = q.trace ++ q.trace = undefined ++ context.result = result ++ resolved(queryChannel, context) ++} ++ ++export function queryFailed(q, error) { ++ const context = q.trace ++ q.trace = undefined ++ rejected(queryChannel, context, error) ++} ++ ++export function connectionClosed(connectionId, error) { ++ closeChannel && closeChannel.hasSubscribers && closeChannel.publish({ connectionId, error }) ++} diff --git a/src/transport.js b/src/transport.js new file mode 100644 index 0000000000000000000000000000000000000000..990ba27822fbfaca189a3a973c87ba10e58f1ec2 @@ -2766,7 +2985,7 @@ index 0000000000000000000000000000000000000000..e8a4bddf7c0f4b6c55ec7c5f33865888 + } +} diff --git a/types/index.d.ts b/types/index.d.ts -index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..7574d4b038b922d31236b0e9bfc3d27f5d18fe75 100644 +index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..e1e888189740e0568616c628ebd0b2d31dcf456f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,4 +1,4 @@ @@ -2948,8 +3167,59 @@ index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..7574d4b038b922d31236b0e9bfc3d27f } interface TransactionSql = {}> extends ISql { -@@ -729,6 +800,12 @@ declare namespace postgres { - +@@ -727,8 +798,63 @@ declare namespace postgres { + prepare(name: string): Promise>; + } + ++ /** Context of the `postgres:query` tracing channel. `query`, `values`, `serverAddress`, `serverPort` and `connectionId` are set when the query is written to a connection, after `start`. */ ++ interface QueryTraceContext { ++ /** SQL text with `$n` placeholders as sent to the server */ ++ query: string | undefined; ++ /** Parameters before they are serialized for the wire, raw */ ++ values: unknown[] | undefined; ++ database: string; ++ user: string; ++ serverAddress: string | undefined; ++ serverPort: number | undefined; ++ connectionId: number | undefined; ++ /** Set before `asyncStart` when the query succeeded */ ++ result?: ResultMeta | Statement | Readable | Writable; ++ /** Set before `error` when the query failed */ ++ error?: Error; ++ } ++ ++ /** Context of the `postgres:connection` tracing channel, one span for each connection acquisition. */ ++ interface ConnectionTraceContext { ++ connectionId: number; ++ database: string; ++ user: string; ++ /** The host of the latest attempt; undefined before the first attempt when several hosts are configured */ ++ serverAddress: string | undefined; ++ serverPort: number | undefined; ++ error?: Error; ++ } ++ ++ /** Context of the `postgres:transaction` tracing channel, for `sql.begin()` and `sql.savepoint()`. */ ++ interface TransactionTraceContext { ++ database: string; ++ user: string; ++ /** The mode string passed to `sql.begin()`; undefined for a savepoint */ ++ options: string | undefined; ++ /** The savepoint name as sent to the server; undefined for `sql.begin()` */ ++ savepoint: string | undefined; ++ /** The name given to `sql.prepare()` */ ++ prepared: string | undefined; ++ /** Set by `sql.begin()` on success: `committed` is false when the transaction was prepared */ ++ result: { committed: boolean } | undefined; ++ error?: Error; ++ } ++ ++ /** Payload of the `postgres:connection:close` channel. */ ++ interface ConnectionCloseMessage { ++ connectionId: number; ++ error: Error | undefined; ++ } ++ interface ReservedSql = {}> extends Sql { release(): void; + From 266e96dfcc876a3049bc4db1a170e381d4e5f86f Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:12:47 +0100 Subject: [PATCH 05/34] test: wait for the cursor closed by break to settle before counting --- tests/tracing.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/tracing.js b/tests/tracing.js index 58ca24c9..6b110114 100644 --- a/tests/tracing.js +++ b/tests/tracing.js @@ -100,8 +100,9 @@ t('Tracing a cursor read with for await settles once, to the end and on break', rows.length break } + await sql`select 1` }) - return [JSON.stringify([2, 2]), settled(events)] + return [JSON.stringify([3, 3]), settled(events)] }) t('Tracing forEach settles once', async() => { From a0d423696b578c8e6e9e95a6403d0f3bb2161dfe Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:13:08 +0100 Subject: [PATCH 06/34] ci: run the dd-trace and Sentry tracing comparison on every pull request The channels rewrite code that dd-trace and Sentry hook into by shape, and npm run test:tracing was only ever run by hand. It packs the fork, starts a throwaway cluster and compares the spans against Postgres.js 3.4.9, so a shape change that loses spans now fails a check instead of reaching a release. --- .github/workflows/test.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 41cd1cd9..49896fcd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -101,6 +101,21 @@ jobs: WORKERD_TLS_PORT: 55433 WORKERD_TLS_CA: ${{ runner.temp }}/server.crt + tracing: + name: Tracing integrations (dd-trace, Sentry) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: ./.github/actions/setup-postgres + with: + version: '18' + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '24' + - run: echo /usr/lib/postgresql/18/bin >> "$GITHUB_PATH" + - run: npm run test:tracing + lint: name: Lint runs-on: ubuntu-24.04 From e1099bc8a5ddb827c7cb5519292052ac20c73132 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:31:37 +0100 Subject: [PATCH 07/34] fix: end transaction spans at settlement and skip tracing a query that is already settled A transaction context was created with a result key set to undefined. Sentry treats a result or error key present at end as a finished synchronous call and closes the span there, so transaction and savepoint spans ended at once. The key now appears only when the commit result is set. A query cancelled before it was awaited was rejected without a trace, then started one when awaited, and the driver dropped it before sending, so its span never closed. A query that has already settled no longer starts a trace. The connection and transaction contexts are now built by helpers in tracing.js next to the query ones, so the payload field names live in one source file. --- src/connection.js | 6 +++--- src/index.js | 12 ++++-------- src/query.js | 2 +- src/tracing.js | 17 +++++++++++++++++ types/index.d.ts | 2 +- 5 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/connection.js b/src/connection.js index 5923e6cf..ca377594 100644 --- a/src/connection.js +++ b/src/connection.js @@ -12,7 +12,7 @@ import clampedTimeout from './timeout.js' import { SSLRequest, tlsConfig, cancelRequest } from './transport.js' import { Query, CLOSE } from './query.js' import b from './bytes.js' -import { connectionChannel, watched, run, resolved, rejected, serverOf, querySent, connectionClosed } from './tracing.js' +import { connectionChannel, watched, run, resolved, rejected, connectionContext, connectionAttempt, querySent, connectionClosed } from './tracing.js' export { Connection } @@ -316,7 +316,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose if (!watched(connectionChannel)) return startAcquisition() - acquisition.trace = { connectionId: id, database, user, ...serverOf(options) } + acquisition.trace = connectionContext(id, options) run(connectionChannel, acquisition.trace, startAcquisition) } @@ -364,7 +364,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose listen(created) socket = created serverIndex = hostIndex - acquisition.trace && (acquisition.trace.serverAddress = serverAddress(), acquisition.trace.serverPort = serverPort()) + acquisition.trace && connectionAttempt(acquisition.trace, serverAddress(), serverPort()) acquisition.attempting = options.socket ? 'custom socket' : options.path || host[hostIndex] + ':' + port[hostIndex] if (options.socket) diff --git a/src/index.js b/src/index.js index c16beb85..f12e3a18 100644 --- a/src/index.js +++ b/src/index.js @@ -28,7 +28,7 @@ import Queue from './queue.js' import { Errors, PostgresError, callback } from './errors.js' import Subscribe from './subscribe.js' import largeObject from './large.js' -import { transactionChannel, watched, traceAsync } from './tracing.js' +import { transactionChannel, watched, traceAsync, transactionContext, transactionCommitted } from './tracing.js' const Lease = { Active: 0, Settling: 1, Released: 2, Ended: 3, Closed: 4 } @@ -355,14 +355,10 @@ function Postgres(a, b) { if (!watched(transactionChannel)) return transact(mode, fn) - const trace = transactionTrace(mode) + const trace = transactionContext(options, mode) return traceAsync(transactionChannel, trace, () => transact(mode, fn, trace)) } - function transactionTrace(mode, savepoint) { - return { database: options.database, user: options.user, options: mode, savepoint, prepared: undefined, result: undefined } - } - async function transact(options, fn, trace) { const l = await lease(true) let savepoints = 0 @@ -448,7 +444,7 @@ function Postgres(a, b) { ) } - trace && (trace.result = { committed: prepare === null }) + trace && transactionCommitted(trace, prepare === null) } return result @@ -460,7 +456,7 @@ function Postgres(a, b) { arguments.length === 1 && (fn = name, name = null) const id = 's' + savepoints++ + (name ? '_' + name : '') return watched(transactionChannel) - ? traceAsync(transactionChannel, transactionTrace(undefined, id), () => scope(fn, id)) + ? traceAsync(transactionChannel, transactionContext(options, undefined, id), () => scope(fn, id)) : scope(fn, id) } diff --git a/src/query.js b/src/query.js index b7bd3e66..4d259036 100644 --- a/src/query.js +++ b/src/query.js @@ -153,7 +153,7 @@ export class Query extends Promise { return this.executed = true - if (!this.initialization && watched(queryChannel)) + if (!this.initialization && !this.settled && watched(queryChannel)) return traceQuery(this, dispatch) await 1 diff --git a/src/tracing.js b/src/tracing.js index 141a8bf2..455b379b 100644 --- a/src/tracing.js +++ b/src/tracing.js @@ -49,6 +49,23 @@ export function serverOf({ host, port, path }) { : { serverAddress: undefined, serverPort: undefined } } +export function connectionContext(connectionId, config) { + return { connectionId, database: config.database, user: config.user, ...serverOf(config) } +} + +export function connectionAttempt(context, serverAddress, serverPort) { + context.serverAddress = serverAddress + context.serverPort = serverPort +} + +export function transactionContext(config, options, savepoint) { + return { database: config.database, user: config.user, options, savepoint, prepared: undefined } +} + +export function transactionCommitted(context, committed) { + context.result = { committed } +} + export function traceQuery(q, fn) { const config = q.handler.config q.trace = { diff --git a/types/index.d.ts b/types/index.d.ts index e1e88818..8476e100 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -837,7 +837,7 @@ declare namespace postgres { /** The name given to `sql.prepare()` */ prepared: string | undefined; /** Set by `sql.begin()` on success: `committed` is false when the transaction was prepared */ - result: { committed: boolean } | undefined; + result?: { committed: boolean }; error?: Error; } From 120e08a56290cc457ac92816c91829dfc26f1875 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:31:37 +0100 Subject: [PATCH 08/34] ci: block a release on the tracing comparison A regression that loses dd-trace or Sentry spans should stop a publish like a failing test does, so the publish job now waits for the tracing job. --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 49896fcd..6776b864 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -133,7 +133,7 @@ jobs: publish: name: Stage on npm if: startsWith(github.ref, 'refs/tags/v') - needs: [node, deno, cloudflare, lint] + needs: [node, deno, cloudflare, lint, tracing] runs-on: ubuntu-24.04 timeout-minutes: 45 environment: npm From dd871e85f57d7f3b9aa41adcb07dcbf2161ccf21 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:31:37 +0100 Subject: [PATCH 09/34] test: cover cancellation, retries and error paths of the tracing channels Adds a check that no context carries a result or error key before it is set, cancel-before-await with several connections, one connection and a pool that must connect, a retried query, a server error and a throwing callback in the middle of a cursor, cancellation from the queue of a reserved connection and of a transaction, and queries rejected as the pool ends. The caller-context test now runs the query inside a store of its own and asserts the bound transform sees it. --- tests/tracing.js | 124 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 4 deletions(-) diff --git a/tests/tracing.js b/tests/tracing.js index 6b110114..80d683c4 100644 --- a/tests/tracing.js +++ b/tests/tracing.js @@ -236,22 +236,25 @@ t('Tracing a connection that fails to authenticate or to connect publishes the e t('Tracing start runs in the AsyncLocalStorage context of the caller', async() => { const storage = new AsyncLocalStorage() const seen = [] - let spans = 0 + const transforms = [] const sql = postgres({ ...options, debug: () => seen.push(storage.getStore()) }) const channel = dc.tracingChannel('postgres:query') const handlers = { start: () => seen.push('start:' + storage.getStore()) } - channel.start.bindStore(storage, () => 'span-' + ++spans) + channel.start.bindStore(storage, () => (transforms.push(storage.getStore()), 'span')) channel.subscribe(handlers) try { await sql`select 1` seen.length = 0 - await sql`select 2` + transforms.length = 0 + await storage.run('caller', async() => { + await sql`select 2` + }) } finally { channel.unsubscribe(handlers) channel.start.unbindStore(storage) await sql.end() } - return [JSON.stringify(['start:span-2', 'span-2']), JSON.stringify(seen)] + return [JSON.stringify([['caller'], ['start:span', 'span']]), JSON.stringify([transforms, seen])] }) t('Tracing a transaction runs its function inside the transaction context', async() => { @@ -305,3 +308,116 @@ t('Nothing is published when nothing subscribes', async() => { return [JSON.stringify([]), JSON.stringify(published)] }) + +t('Tracing contexts have no result or error key until one is set', async() => { + const events = await traced(['query', 'connection', 'transaction'], sql => sql.begin(async sql => { + await sql.savepoint(sql => sql`select 1 as x`) + })) + const early = events.filter(x => x.stage === 'start' || x.stage === 'end') + return [ + JSON.stringify([['connection', 'query', 'transaction'], 0]), + JSON.stringify([[...new Set(early.map(x => x.name))].sort(), early.filter(x => 'result' in x.context || 'error' in x.context).length]) + ] +}) + +const cancelledBeforeAwait = async(extra, warm) => { + const sql = postgres({ ...options, ...extra }) + warm && await sql`select 1` + const { events, stop } = collect('query') + const query = sql`select 2 as x` + query.cancel() + const code = await query.catch(e => e.code) + await sql`select 3 as x` + stop() + await sql.end() + return JSON.stringify([code, count(events, 'start'), count(events, 'asyncEnd')]) +} + +t('Tracing a query cancelled before it is awaited opens no span, with several connections', async() => + [JSON.stringify(['57014', 1, 1]), await cancelledBeforeAwait({ max: 2 }, true)] +) + +t('Tracing a query cancelled before it is awaited opens no span, with one connection', async() => + [JSON.stringify(['57014', 1, 1]), await cancelledBeforeAwait({ max: 1 }, true)] +) + +t('Tracing a query cancelled before it is awaited opens no span, on a pool that has to connect', async() => + [JSON.stringify(['57014', 1, 1]), await cancelledBeforeAwait({ max: 2 }, false)] +) + +t('Tracing a retried query settles once and is sent again', async() => { + const sql = postgres(options) + const { events, stop } = collect('query', 'postgres:query:send') + await sql`drop table if exists tracing_retry` + await sql`create table tracing_retry (name text)` + await sql`insert into tracing_retry values ('1')` + await sql`select name from tracing_retry` + await sql`alter table tracing_retry alter column name type int using name::integer` + const [row] = await sql`select name from tracing_retry` + await sql`drop table tracing_retry` + stop() + await sql.end() + const sends = of(events, 'postgres:query:send').filter(x => x.context.query === 'select name from tracing_retry').length + return [JSON.stringify([1, 7, 7, 3]), JSON.stringify([row.name, count(events, 'start'), count(events, 'asyncEnd'), sends])] +}) + +t('Tracing a server error in the middle of a cursor settles once with the error', async() => { + const events = await traced(['query'], async sql => { + await sql`select 1 / (x - 3) from generate_series(1, 5) x`.cursor(2, () => undefined).catch(() => undefined) + await sql`select 1` + }) + const [first] = ended(events) + return [JSON.stringify([2, 2, '22012']), JSON.stringify([count(events, 'start'), count(events, 'asyncEnd'), first.error && first.error.code])] +}) + +t('Tracing a cursor callback that throws settles once with the error', async() => { + const events = await traced(['query'], async sql => { + await sql`select * from generate_series(1, 5)`.cursor(2, () => { + throw new Error('boom') + }).catch(() => undefined) + await sql`select 1` + }) + const [first] = ended(events) + return [JSON.stringify([2, 2, 'boom']), JSON.stringify([count(events, 'start'), count(events, 'asyncEnd'), first.error && first.error.message])] +}) + +t('Tracing a query cancelled in the queue of a reserved connection settles once', async() => { + const sql = postgres({ ...options, max_pipeline: 1 }) + const { events, stop } = collect('query') + const reserved = await sql.reserve() + const first = reserved`select pg_sleep(0.1)`.execute() + const second = reserved`select 2 as x`.execute() + const third = reserved`select 3 as x`.execute() + const code = await third.cancel().then(() => third.catch(e => e.code)) + await Promise.allSettled([first, second]) + reserved.release() + stop() + await sql.end() + return [JSON.stringify(['57014', 3, 3]), JSON.stringify([code, count(events, 'start'), count(events, 'asyncEnd')])] +}) + +t('Tracing a query cancelled in the queue of a transaction settles once', async() => { + const sql = postgres({ ...options, max_pipeline: 1 }) + const { events, stop } = collect('query') + await sql.begin(async sql => { + const queries = [sql`select pg_sleep(0.1)`.execute(), sql`select 2 as x`.execute(), sql`select 3 as x`.execute()] + await queries[2].cancel() + await Promise.allSettled(queries) + }).catch(() => undefined) + stop() + await sql.end() + return [ + JSON.stringify([true, 1]), + JSON.stringify([count(events, 'start') === count(events, 'asyncEnd'), ended(events).filter(x => x.error && x.error.code === '57014').length]) + ] +}) + +t('Tracing queries rejected because the pool is ending settle once', async() => { + const sql = postgres({ ...options, max: 1 }) + const { events, stop } = collect('query') + const running = sql`select pg_sleep(0.1)`.execute() + const queued = sql`select 2 as x`.execute() + await Promise.allSettled([running, queued, sql.end()]) + stop() + return [JSON.stringify([2, 2]), settled(events)] +}) From 3492bddb0d39e2fb0a01b06d1a9e521322c82970 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:31:37 +0100 Subject: [PATCH 10/34] docs: fix the tracing example for failed queries and note when send is published asyncEnd also fires after an error, where result is absent, so the example reads it with optional chaining. postgres:query:send is published only for queries that had a postgres:query subscriber when they started. --- docs/api.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api.md b/docs/api.md index 013ef624..8072ca12 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1519,7 +1519,7 @@ The driver publishes `diagnostics_channel` events for queries, connections and t | `postgres:query` | `TracingChannel` | One span per query, from the call that runs it until the server answers or the query fails. | | `postgres:connection` | `TracingChannel` | One span per connection acquisition: socket, TLS, authentication, startup and type fetching, up to the connection serving queries. Retries and other hosts stay inside the same span. | | `postgres:transaction` | `TracingChannel` | `sql.begin()` and, nested in it, each `sql.savepoint()`. | -| `postgres:query:send` | `Channel` | The query's context again, each time it is written to a connection. | +| `postgres:query:send` | `Channel` | The query's context again, each time it is written to a connection. Published only for queries that had a `postgres:query` subscriber when they started. | | `postgres:connection:close` | `Channel` | `{ connectionId, error }` whenever a connection closes, including a failed connection attempt. `error` is undefined for a normal close. | The `TracingChannel`s publish in the order Node's `tracePromise` does: `start` and `end` in the caller's synchronous code, then for a failure `error`, and for both outcomes `asyncStart` and `asyncEnd`, with `context.result` or `context.error` set first. `start` is published with `runStores`, so a store bound to it, such as an `AsyncLocalStorage` span context, is current in the code that starts the query. Every query publishes `asyncStart` and `asyncEnd` exactly once. That includes cursors read with `for await` or a callback, `forEach`, `COPY` streams (once the copy is over), `describe()`, `simple()`, `unsafe()`, `file()`, pipelined queries, retried queries and queries cancelled before they were sent. The driver's own startup queries (type fetching, `target_session_attrs`) are not published; they belong to the connection span. @@ -1540,7 +1540,7 @@ The context objects are the `QueryTraceContext`, `ConnectionTraceContext` and `T | `savepoint` | transaction | at `start` for `sql.savepoint()`: the savepoint name as sent, such as `s0_name` | | `prepared` | transaction | when `sql.prepare(name)` is called | -The text and parameters are known only after the driver has serialized the query for a connection, so they are not in the context at `start`. Read them at `asyncEnd` or `error`, or rename the span then. A query written again after a retry publishes `postgres:query:send` again, with the same context. The time between `start` and the first `postgres:query:send` is the wait for a connection, and between that and `asyncEnd` the time on the server. +The `result` and `error` keys do not exist on a context until they are set, so a subscriber can tell a finished call from a started one by `'result' in context || 'error' in context`. The text and parameters are known only after the driver has serialized the query for a connection, so they are not in the context at `start`. Read them at `asyncEnd` or `error`, or rename the span then. A query written again after a retry publishes `postgres:query:send` again, with the same context. The time between `start` and the first `postgres:query:send` is the wait for a connection, and between that and `asyncEnd` the time on the server. Parameters are included as they are. Decide in your subscriber what to record, since values can hold personal data. @@ -1556,7 +1556,7 @@ channel.subscribe({ end() {}, asyncStart() {}, asyncEnd(context) { - console.log(context.query, context.result.count, performance.now() - context.startedAt) + console.log(context.query, context.result?.count, performance.now() - context.startedAt) }, error(context) { console.error(context.query, context.error) From 01e9642408a43aaa510a427627c58dfeb1e2b24e Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:31:37 +0100 Subject: [PATCH 11/34] docs: refresh the diff since 3.4.9 --- docs/changes-since-3.4.9.diff | 99 ++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 43 deletions(-) diff --git a/docs/changes-since-3.4.9.diff b/docs/changes-since-3.4.9.diff index 0c8c5687..4bb09010 100644 --- a/docs/changes-since-3.4.9.diff +++ b/docs/changes-since-3.4.9.diff @@ -1,5 +1,5 @@ diff --git a/src/connection.js b/src/connection.js -index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..5923e6cf00e74d4474ca38ad37fc05b51a18c6e6 100644 +index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08abe4a95a 100644 --- a/src/connection.js +++ b/src/connection.js @@ -4,24 +4,42 @@ import crypto from 'crypto' @@ -16,7 +16,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..5923e6cf00e74d4474ca38ad37fc05b5 +import { SSLRequest, tlsConfig, cancelRequest } from './transport.js' import { Query, CLOSE } from './query.js' import b from './bytes.js' -+import { connectionChannel, watched, run, resolved, rejected, serverOf, querySent, connectionClosed } from './tracing.js' ++import { connectionChannel, watched, run, resolved, rejected, connectionContext, connectionAttempt, querySent, connectionClosed } from './tracing.js' -export default Connection +export { Connection } @@ -317,7 +317,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..5923e6cf00e74d4474ca38ad37fc05b5 + if (!watched(connectionChannel)) + return startAcquisition() + -+ acquisition.trace = { connectionId: id, database, user, ...serverOf(options) } ++ acquisition.trace = connectionContext(id, options) + run(connectionChannel, acquisition.trace, startAcquisition) + } + @@ -365,7 +365,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..5923e6cf00e74d4474ca38ad37fc05b5 + listen(created) + socket = created + serverIndex = hostIndex -+ acquisition.trace && (acquisition.trace.serverAddress = serverAddress(), acquisition.trace.serverPort = serverPort()) ++ acquisition.trace && connectionAttempt(acquisition.trace, serverAddress(), serverPort()) + acquisition.attempting = options.socket ? 'custom socket' : options.path || host[hostIndex] + ':' + port[hostIndex] + + if (options.socket) @@ -1509,7 +1509,7 @@ index 0ff83c428bec774e11417b8e1f033dc3305c18cf..023c3ba1bd113e6f5508ab079efcf220 code: x, errno: x, diff --git a/src/index.js b/src/index.js -index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0ac788951 100644 +index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f7f51a399 100644 --- a/src/index.js +++ b/src/index.js @@ -3,10 +3,12 @@ import fs from 'fs' @@ -1539,7 +1539,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 +import { Errors, PostgresError, callback } from './errors.js' import Subscribe from './subscribe.js' import largeObject from './large.js' -+import { transactionChannel, watched, traceAsync } from './tracing.js' ++import { transactionChannel, watched, traceAsync, transactionContext, transactionCommitted } from './tracing.js' + +const Lease = { Active: 0, Settling: 1, Released: 2, Ended: 3, Closed: 4 } @@ -1695,7 +1695,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 return { state: result.state, unlisten } async function unlisten() { -@@ -200,62 +264,159 @@ function Postgres(a, b) { +@@ -200,62 +264,155 @@ function Postgres(a, b) { return await sql`select pg_notify(${ channel }, ${ '' + payload })` } @@ -1759,20 +1759,20 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 + ? Errors.generic('CONNECTION_RELEASED', 'Reserved connection used after release()') + : Errors.generic('TRANSACTION_ENDED', 'Transaction handle used after its transaction ended') + } -+ -+ function stop(l, state) { -+ if (l.state === Lease.Released || l.state === Lease.Ended) -+ return null - move(c, reserved) - c.reserved = () => queue.length - ? c.execute(queue.shift()) - : move(c, reserved) - c.reserved.release = true ++ function stop(l, state) { ++ if (l.state === Lease.Released || l.state === Lease.Ended) ++ return null + + l.state = state + while (l.queue.length) + l.queue.shift().reject(leaseError(l)) - ++ + const c = l.connection + if (c.owner !== l) + return null @@ -1813,14 +1813,10 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 + if (!watched(transactionChannel)) + return transact(mode, fn) + -+ const trace = transactionTrace(mode) ++ const trace = transactionContext(options, mode) + return traceAsync(transactionChannel, trace, () => transact(mode, fn, trace)) + } + -+ function transactionTrace(mode, savepoint) { -+ return { database: options.database, user: options.user, options: mode, savepoint, prepared: undefined, result: undefined } -+ } -+ + async function transact(options, fn, trace) { + const l = await lease(true) let savepoints = 0 @@ -1863,15 +1859,15 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 + function release() { + const c = stop(l, Lease.Ended) + c && c.release() ++ } ++ ++ function terminate() { ++ const c = stop(l, Lease.Ended) ++ c && c.terminate() } - async function scope(c, fn, name) { - const sql = Sql(handler) -+ function terminate() { -+ const c = stop(l, Lease.Ended) -+ c && c.terminate() -+ } -+ + async function scope(fn, name) { + const sql = Sql(track) sql.savepoint = savepoint @@ -1891,7 +1887,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 try { result = await new Promise((resolve, reject) => { const x = fn(sql) -@@ -265,17 +426,29 @@ function Postgres(a, b) { +@@ -265,17 +422,29 @@ function Postgres(a, b) { if (uncaughtError) throw uncaughtError } catch (e) { @@ -1923,18 +1919,18 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 + ) + } + -+ trace && (trace.result = { committed: prepare === null }) ++ trace && transactionCommitted(trace, prepare === null) } return result -@@ -285,23 +458,21 @@ function Postgres(a, b) { +@@ -285,23 +454,21 @@ function Postgres(a, b) { return savepoint(sql => sql.apply(sql, arguments)) arguments.length === 1 && (fn = name, name = null) - return scope(c, fn, 's' + savepoints++ + (name ? '_' + name : '')) + const id = 's' + savepoints++ + (name ? '_' + name : '') + return watched(transactionChannel) -+ ? traceAsync(transactionChannel, transactionTrace(undefined, id), () => scope(fn, id)) ++ ? traceAsync(transactionChannel, transactionContext(options, undefined, id), () => scope(fn, id)) + : scope(fn, id) } @@ -1960,7 +1956,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 } } -@@ -310,7 +481,7 @@ function Postgres(a, b) { +@@ -310,7 +477,7 @@ function Postgres(a, b) { queue.push(c) c.queue = queue queue === open @@ -1969,7 +1965,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 : c.idleTimer.cancel() return c } -@@ -330,15 +501,51 @@ function Postgres(a, b) { +@@ -330,15 +497,51 @@ function Postgres(a, b) { if (ending) return query.reject(Errors.connection('CONNECTION_ENDED', options, options)) @@ -2026,7 +2022,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 } function go(c, query) { -@@ -348,17 +555,18 @@ function Postgres(a, b) { +@@ -348,17 +551,18 @@ function Postgres(a, b) { } function cancel(query) { @@ -2055,7 +2051,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 }) } -@@ -368,13 +576,23 @@ function Postgres(a, b) { +@@ -368,13 +572,23 @@ function Postgres(a, b) { await 1 let timer @@ -2086,7 +2082,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 } async function close() { -@@ -382,9 +600,9 @@ function Postgres(a, b) { +@@ -382,9 +596,9 @@ function Postgres(a, b) { } async function destroy(resolve) { @@ -2097,7 +2093,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 resolve() } -@@ -398,12 +616,18 @@ function Postgres(a, b) { +@@ -398,12 +612,18 @@ function Postgres(a, b) { move(c, ended) } @@ -2119,7 +2115,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 while (ready && queries.length && max-- > 0) { const query = queries.shift() -@@ -418,12 +642,60 @@ function Postgres(a, b) { +@@ -418,12 +638,60 @@ function Postgres(a, b) { : move(c, full) } @@ -2184,7 +2180,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 } } -@@ -453,16 +725,17 @@ function parseOptions(a, b) { +@@ -453,16 +721,17 @@ function parseOptions(a, b) { connect_timeout : 30, max_lifetime : max_lifetime, max_pipeline : 100, @@ -2204,7 +2200,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 host : Array.isArray(host) ? host : host.split(',').map(x => x.split(':')[0]), port : Array.isArray(port) ? port : host.split(',').map(x => parseInt(x.split(':')[1] || port)), path : o.path || host.indexOf('/') > -1 && host + '/.s.PGSQL.' + port, -@@ -472,8 +745,8 @@ function parseOptions(a, b) { +@@ -472,8 +741,8 @@ function parseOptions(a, b) { ...Object.entries(defaults).reduce( (acc, [k, d]) => { const value = k in o ? o[k] : k in query @@ -2215,7 +2211,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 acc[k] = typeof value === 'string' && ints.includes(k) ? +value : value -@@ -488,16 +761,26 @@ function parseOptions(a, b) { +@@ -488,16 +757,26 @@ function parseOptions(a, b) { }, types : o.types || {}, target_session_attrs: tsa(o, url, env), @@ -2243,7 +2239,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 } function tsa(o, url, env) { -@@ -508,8 +791,12 @@ function tsa(o, url, env) { +@@ -508,8 +787,12 @@ function tsa(o, url, env) { throw new Error('target_session_attrs ' + x + ' is not supported') } @@ -2259,7 +2255,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..c16beb85ae05b83b616c6fd59fdd41d0 function max_lifetime() { diff --git a/src/query.js b/src/query.js -index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..b7bd3e66c5f16349d822427509720cb38af8ebca 100644 +index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..4d25903697001cf4c04c4169706cc2c8716c592d 100644 --- a/src/query.js +++ b/src/query.js @@ -1,3 +1,5 @@ @@ -2322,7 +2318,7 @@ index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..b7bd3e66c5f16349d822427509720cb3 + return + + this.executed = true -+ if (!this.initialization && watched(queryChannel)) ++ if (!this.initialization && !this.settled && watched(queryChannel)) + return traceQuery(this, dispatch) + + await 1 @@ -2658,10 +2654,10 @@ index 0000000000000000000000000000000000000000..bcdf4c1894e0f84957d7ff3890bea9d8 +} diff --git a/src/tracing.js b/src/tracing.js new file mode 100644 -index 0000000000000000000000000000000000000000..141a8bf28946f2154758bb539fec2b51dacc8b1d +index 0000000000000000000000000000000000000000..455b379b2f255ae8dbb6ebbc453afcd1fa317d24 --- /dev/null +++ b/src/tracing.js -@@ -0,0 +1,93 @@ +@@ -0,0 +1,110 @@ +import dc from 'diagnostics_channel' + +const supported = Boolean(dc) && typeof dc.tracingChannel === 'function' @@ -2713,6 +2709,23 @@ index 0000000000000000000000000000000000000000..141a8bf28946f2154758bb539fec2b51 + : { serverAddress: undefined, serverPort: undefined } +} + ++export function connectionContext(connectionId, config) { ++ return { connectionId, database: config.database, user: config.user, ...serverOf(config) } ++} ++ ++export function connectionAttempt(context, serverAddress, serverPort) { ++ context.serverAddress = serverAddress ++ context.serverPort = serverPort ++} ++ ++export function transactionContext(config, options, savepoint) { ++ return { database: config.database, user: config.user, options, savepoint, prepared: undefined } ++} ++ ++export function transactionCommitted(context, committed) { ++ context.result = { committed } ++} ++ +export function traceQuery(q, fn) { + const config = q.handler.config + q.trace = { @@ -2985,7 +2998,7 @@ index 0000000000000000000000000000000000000000..e8a4bddf7c0f4b6c55ec7c5f33865888 + } +} diff --git a/types/index.d.ts b/types/index.d.ts -index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..e1e888189740e0568616c628ebd0b2d31dcf456f 100644 +index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..8476e100a816110774c2607a5a5b0c6ccc69cb4f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,4 +1,4 @@ @@ -3210,7 +3223,7 @@ index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..e1e888189740e0568616c628ebd0b2d3 + /** The name given to `sql.prepare()` */ + prepared: string | undefined; + /** Set by `sql.begin()` on success: `committed` is false when the transaction was prepared */ -+ result: { committed: boolean } | undefined; ++ result?: { committed: boolean }; + error?: Error; + } + From 2cf639b287026d27719f85dc5b823cfae474bc6c Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:39:03 +0100 Subject: [PATCH 12/34] feat: shape the tracing payloads like node-postgres and add the pool channels (#73) The query, connect and pool payloads now use the field names of the node-postgres tracing channels, so a subscriber written for one driver reads the other with few changes. postgres:query carries { query: { text, name }, client: { database, host, port, user, ssl, processID } }. Parameter values are no longer published. The client holds the configured values at start and the actual host, port, tls state and backend pid once the query is written to a connection. postgres:connection is now postgres:connect with { connection: { database, host, port, user, ssl } }, and postgres:connection:close is now postgres:pool:remove with { client: { processID }, error }. postgres:pool:connect traces every lease acquisition (sql.reserve() and sql.begin()) with the pool counts at start and { processID, reused } on success. Its failures come from the place the lease already settles. postgres:pool:release is published when a lease gives its connection back, with the error the lease ended with: the failure that ended a transaction before its connection was released, or the error that closed the connection. A commit the server answered with a rollback is reported after the release, so the release carries no error then. postgres:transaction uses the same client object. --- src/connection.js | 37 ++++++++++++++----- src/index.js | 39 ++++++++++++++++---- src/tracing.js | 84 +++++++++++++++++++++++++++----------------- tests-types/index.ts | 13 ++++--- types/index.d.ts | 72 +++++++++++++++++++++++++------------ 5 files changed, 170 insertions(+), 75 deletions(-) diff --git a/src/connection.js b/src/connection.js index ca377594..cdb40559 100644 --- a/src/connection.js +++ b/src/connection.js @@ -12,7 +12,18 @@ import clampedTimeout from './timeout.js' import { SSLRequest, tlsConfig, cancelRequest } from './transport.js' import { Query, CLOSE } from './query.js' import b from './bytes.js' -import { connectionChannel, watched, run, resolved, rejected, connectionContext, connectionAttempt, querySent, connectionClosed } from './tracing.js' +import { + connectChannel, + watched, + run, + resolved, + rejected, + connectContext, + connectAttempt, + connectSecured, + querySent, + poolRemoved +} from './tracing.js' export { Connection } @@ -115,6 +126,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose , remaining = 0 , hostIndex = 0 , serverIndex = 0 + , secured = false , length = 0 , rows = 0 , copyPaused = false @@ -245,6 +257,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose get active() { return query ? 1 : 0 }, get pipelined() { return sent.length }, get accepting() { return accepting() }, + get session() { return session() }, get copying() { return stream !== null }, get copyHeld() { return stream !== null && !stream.writableEnded }, get acquiring() { return acquisition && !acquisition.owner.cancelled ? 1 : 0 }, @@ -303,6 +316,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose return options.path || host[serverIndex] } + function session() { + return { host: serverAddress(), port: serverPort(), ssl: secured, processID: backend.pid } + } + function serverPort() { return options.path ? undefined : port[serverIndex] } @@ -313,11 +330,11 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose function acquire(owner) { acquisition = { owner, ending: false, pass: 'standby', hostsTried: 0, attempting: null, mismatches: [], lastError: null, trace: undefined } - if (!watched(connectionChannel)) + if (!watched(connectChannel)) return startAcquisition() - acquisition.trace = connectionContext(id, options) - run(connectionChannel, acquisition.trace, startAcquisition) + acquisition.trace = connectContext(options) + run(connectChannel, acquisition.trace, startAcquisition) } function startAcquisition() { @@ -364,7 +381,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose listen(created) socket = created serverIndex = hostIndex - acquisition.trace && connectionAttempt(acquisition.trace, serverAddress(), serverPort()) + secured = false + acquisition.trace && connectAttempt(acquisition.trace, serverAddress(), serverPort()) acquisition.attempting = options.socket ? 'custom socket' : options.path || host[hostIndex] + ':' + port[hostIndex] if (options.socket) @@ -397,6 +415,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose const config = tlsConfig(options, raw) raw.removeAllListeners() socket = tls.connect(config) + secured = true socket.on('secureConnect', () => dispatch('secure')) listen(socket) } catch (err) { @@ -508,10 +527,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose acquisition = null endWaiters = [] transition(Phase.Closed) - a && a.trace && rejected(connectionChannel, a.trace, err) + a && a.trace && rejected(connectChannel, a.trace, err) a && queryError(a.owner, err) waiters.forEach(resolve => resolve()) - connectionClosed(id, reported) + poolRemoved(a ? undefined : backend.pid, reported) onclose(connection, reported) } @@ -542,7 +561,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose transition(Phase.Ready) clearAcquisitionTimers() acquisition = null - trace && resolved(connectionChannel, trace) + trace && (connectSecured(trace, secured), resolved(connectChannel, trace)) options.shared.retries = 0 lifeTimer.start() @@ -668,7 +687,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose : { string, types, name: q.prepare ? statementId + statementCount++ : '' } typeof options.debug === 'function' && callback(options.debug, id, string, parameters, types) - q.trace && querySent(q, id, serverAddress(), serverPort()) + q.trace && querySent(q, session()) } function write(x, fn) { diff --git a/src/index.js b/src/index.js index f12e3a18..a554f987 100644 --- a/src/index.js +++ b/src/index.js @@ -28,7 +28,18 @@ import Queue from './queue.js' import { Errors, PostgresError, callback } from './errors.js' import Subscribe from './subscribe.js' import largeObject from './large.js' -import { transactionChannel, watched, traceAsync, transactionContext, transactionCommitted } from './tracing.js' +import { + transactionChannel, + poolConnectChannel, + watched, + traceAsync, + transactionContext, + transactionSession, + transactionCommitted, + poolContext, + poolAcquired, + poolReleased +} from './tracing.js' const Lease = { Active: 0, Settling: 1, Released: 2, Ended: 3, Closed: 4 } @@ -264,11 +275,20 @@ function Postgres(a, b) { return await sql`select pg_notify(${ channel }, ${ '' + payload })` } - async function lease(auto) { + function lease(auto) { + if (!watched(poolConnectChannel)) + return acquire(auto) + + const trace = poolContext(stats()) + return traceAsync(poolConnectChannel, trace, () => acquire(auto, trace)) + } + + async function acquire(auto, trace) { if (ending) throw Errors.connection('CONNECTION_ENDED', options) - const l = { state: null, connection: null, error: null, onclose: null, queue: Queue() } + const l = { state: null, connection: null, error: null, outcome: undefined, onclose: null, queue: Queue() } + let reused = true const idle = takeOpen() idle ? own(idle, l) @@ -277,11 +297,12 @@ function Postgres(a, b) { : await new Promise((resolve, reject) => { const query = { reserve: c => resolve(own(c, l)), reject } closed.length - ? connect(closed.shift(), query) + ? (reused = false, connect(closed.shift(), query)) : allHeldByCopy() ? reject(copyInProgress()) : queries.push(query) }) + trace && poolAcquired(trace, l.connection.session.processID, reused) return l } @@ -320,10 +341,12 @@ function Postgres(a, b) { if (l.state === Lease.Released || l.state === Lease.Ended) return null + const lost = l.state === Lease.Closed l.state = state while (l.queue.length) l.queue.shift().reject(leaseError(l)) + poolReleased(l.connection, lost ? l.error : l.outcome) const c = l.connection if (c.owner !== l) return null @@ -355,12 +378,13 @@ function Postgres(a, b) { if (!watched(transactionChannel)) return transact(mode, fn) - const trace = transactionContext(options, mode) + const trace = transactionContext(options, undefined, mode) return traceAsync(transactionChannel, trace, () => transact(mode, fn, trace)) } async function transact(options, fn, trace) { const l = await lease(true) + trace && transactionSession(trace, l.connection.session) let savepoints = 0 , prepare = null @@ -370,6 +394,7 @@ function Postgres(a, b) { async function transaction() { await Sql(handler).unsafe('begin ' + options.replace(/[^a-z ]/ig, ''), []).execute().catch(e => { + l.outcome = e e instanceof PostgresError && release() throw e }) @@ -382,6 +407,7 @@ function Postgres(a, b) { try { result = await statement(Sql(q => handler(q, Lease.Settling))) } catch (e) { + l.outcome = l.outcome || e terminate() throw e } @@ -422,6 +448,7 @@ function Postgres(a, b) { if (uncaughtError) throw uncaughtError } catch (e) { + name || (l.outcome = e) l.state === Lease.Active && await (name ? sql.unsafe('rollback to ' + escapeIdentifier(name)) : finish(final => final.unsafe('rollback')) @@ -456,7 +483,7 @@ function Postgres(a, b) { arguments.length === 1 && (fn = name, name = null) const id = 's' + savepoints++ + (name ? '_' + name : '') return watched(transactionChannel) - ? traceAsync(transactionChannel, transactionContext(options, undefined, id), () => scope(fn, id)) + ? traceAsync(transactionChannel, transactionContext(options, l.connection.session, undefined, id), () => scope(fn, id)) : scope(fn, id) } diff --git a/src/tracing.js b/src/tracing.js index 455b379b..3de24120 100644 --- a/src/tracing.js +++ b/src/tracing.js @@ -3,10 +3,12 @@ import dc from 'diagnostics_channel' const supported = Boolean(dc) && typeof dc.tracingChannel === 'function' export const queryChannel = supported ? dc.tracingChannel('postgres:query') : undefined -export const connectionChannel = supported ? dc.tracingChannel('postgres:connection') : undefined +export const connectChannel = supported ? dc.tracingChannel('postgres:connect') : undefined export const transactionChannel = supported ? dc.tracingChannel('postgres:transaction') : undefined +export const poolConnectChannel = supported ? dc.tracingChannel('postgres:pool:connect') : undefined export const sendChannel = supported ? dc.channel('postgres:query:send') : undefined -export const closeChannel = supported ? dc.channel('postgres:connection:close') : undefined +export const poolReleaseChannel = supported ? dc.channel('postgres:pool:release') : undefined +export const poolRemoveChannel = supported ? dc.channel('postgres:pool:remove') : undefined export function watched(channel) { return channel !== undefined && (channel.hasSubscribers ?? channel.start.hasSubscribers) @@ -41,51 +43,71 @@ export function traceAsync(channel, context, fn) { ) } -export function serverOf({ host, port, path }) { +function serverOf({ host, port, path }) { return path - ? { serverAddress: path, serverPort: undefined } - : host.length === 1 - ? { serverAddress: host[0], serverPort: port[0] } - : { serverAddress: undefined, serverPort: undefined } + ? { host: path, port: undefined } + : host && host.length === 1 + ? { host: host[0], port: port[0] } + : { host: undefined, port: undefined } } -export function connectionContext(connectionId, config) { - return { connectionId, database: config.database, user: config.user, ...serverOf(config) } +function clientOf(config) { + return { database: config.database, ...serverOf(config), user: config.user, ssl: Boolean(config.ssl), processID: undefined } } -export function connectionAttempt(context, serverAddress, serverPort) { - context.serverAddress = serverAddress - context.serverPort = serverPort +export function connectContext(config) { + const { database, host, port, user, ssl } = clientOf(config) + return { connection: { database, host, port, user, ssl } } } -export function transactionContext(config, options, savepoint) { - return { database: config.database, user: config.user, options, savepoint, prepared: undefined } +export function connectAttempt(context, host, port) { + context.connection.host = host + context.connection.port = port +} + +export function connectSecured(context, ssl) { + context.connection.ssl = ssl +} + +export function transactionContext(config, session, options, savepoint) { + return { client: { ...clientOf(config), ...session }, options, savepoint, prepared: undefined } +} + +export function transactionSession(context, session) { + Object.assign(context.client, session) } export function transactionCommitted(context, committed) { context.result = { committed } } +export function poolContext({ totalCount, idleCount, waitingCount, max }) { + return { pool: { totalCount, idleCount, waitingCount, maxSize: max } } +} + +export function poolAcquired(context, processID, reused) { + context.client = { processID, reused } +} + +export function poolReleased(connection, error) { + if (poolReleaseChannel && poolReleaseChannel.hasSubscribers) + poolReleaseChannel.publish({ client: { processID: connection.session.processID }, error }) +} + +export function poolRemoved(processID, error) { + poolRemoveChannel && poolRemoveChannel.hasSubscribers && poolRemoveChannel.publish({ client: { processID }, error }) +} + export function traceQuery(q, fn) { - const config = q.handler.config - q.trace = { - query: undefined, - values: undefined, - database: config && config.database, - user: config && config.user, - ...config && serverOf(config), - connectionId: undefined - } + q.trace = { query: { text: undefined, name: undefined }, client: clientOf(q.handler.config || {}) } return run(queryChannel, q.trace, fn, q) } -export function querySent(q, connectionId, serverAddress, serverPort) { +export function querySent(q, session) { const context = q.trace - context.query = q.string - context.values = q.parameters - context.connectionId = connectionId - context.serverAddress = serverAddress - context.serverPort = serverPort + context.query.text = q.string + context.query.name = q.statement.name || undefined + Object.assign(context.client, session) sendChannel.hasSubscribers && sendChannel.publish(context) } @@ -104,7 +126,3 @@ export function queryFailed(q, error) { q.trace = undefined rejected(queryChannel, context, error) } - -export function connectionClosed(connectionId, error) { - closeChannel && closeChannel.hasSubscribers && closeChannel.publish({ connectionId, error }) -} diff --git a/tests-types/index.ts b/tests-types/index.ts index 3f45717b..b1549e8f 100644 --- a/tests-types/index.ts +++ b/tests-types/index.ts @@ -28,8 +28,11 @@ new postgres.PostgresError('boom') // @ts-expect-error new postgres.PostgresError(42) -const queryTrace: postgres.QueryTraceContext = { query: undefined, values: undefined, database: 'a', user: 'b', serverAddress: undefined, serverPort: undefined, connectionId: undefined } -const connectionTrace: postgres.ConnectionTraceContext = { connectionId: 1, database: 'a', user: 'b', serverAddress: 'localhost', serverPort: 5432 } -const transactionTrace: postgres.TransactionTraceContext = { database: 'a', user: 'b', options: 'read write', savepoint: undefined, prepared: undefined, result: { committed: true } } -const closeMessage: postgres.ConnectionCloseMessage = { connectionId: 1, error: undefined } -void [queryTrace, connectionTrace, transactionTrace, closeMessage] +const client: postgres.TraceClient = { database: 'a', host: 'localhost', port: 5432, user: 'b', ssl: false, processID: undefined } +const queryTrace: postgres.QueryTraceContext = { query: { text: undefined, name: undefined }, client } +const connectTrace: postgres.ConnectTraceContext = { connection: { database: 'a', host: 'localhost', port: 5432, user: 'b', ssl: false } } +const poolConnectTrace: postgres.PoolConnectTraceContext = { pool: { totalCount: 1, idleCount: 0, waitingCount: 0, maxSize: 10 }, client: { processID: 1, reused: true } } +const transactionTrace: postgres.TransactionTraceContext = { client, options: 'read write', savepoint: undefined, prepared: undefined, result: { committed: true } } +const releaseMessage: postgres.PoolReleaseMessage = { client: { processID: 1 }, error: undefined } +const removeMessage: postgres.PoolRemoveMessage = { client: { processID: 1 }, error: undefined } +void [queryTrace, connectTrace, poolConnectTrace, transactionTrace, releaseMessage, removeMessage] diff --git a/types/index.d.ts b/types/index.d.ts index 8476e100..2c07940c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -798,38 +798,58 @@ declare namespace postgres { prepare(name: string): Promise>; } - /** Context of the `postgres:query` tracing channel. `query`, `values`, `serverAddress`, `serverPort` and `connectionId` are set when the query is written to a connection, after `start`. */ - interface QueryTraceContext { - /** SQL text with `$n` placeholders as sent to the server */ - query: string | undefined; - /** Parameters before they are serialized for the wire, raw */ - values: unknown[] | undefined; + /** The connection a traced call runs on. Host, port and ssl are those of the connection once it is known. */ + interface TraceClient { database: string; + host: string | undefined; + port: number | undefined; user: string; - serverAddress: string | undefined; - serverPort: number | undefined; - connectionId: number | undefined; + ssl: boolean; + /** Backend process id; set once the call is written to a connection */ + processID: number | undefined; + } + + /** Context of the `postgres:query` tracing channel. `query` and the connection fields of `client` are set when the query is written to a connection, after `start`. */ + interface QueryTraceContext { + query: { + /** SQL text with `$n` placeholders as sent to the server */ + text: string | undefined; + /** Prepared statement name; undefined for an unnamed statement */ + name: string | undefined; + }; + client: TraceClient; /** Set before `asyncStart` when the query succeeded */ result?: ResultMeta | Statement | Readable | Writable; /** Set before `error` when the query failed */ error?: Error; } - /** Context of the `postgres:connection` tracing channel, one span for each connection acquisition. */ - interface ConnectionTraceContext { - connectionId: number; - database: string; - user: string; - /** The host of the latest attempt; undefined before the first attempt when several hosts are configured */ - serverAddress: string | undefined; - serverPort: number | undefined; + /** Context of the `postgres:connect` tracing channel, one span for each connection acquisition. */ + interface ConnectTraceContext { + connection: Omit; + error?: Error; + } + + /** Context of the `postgres:pool:connect` tracing channel, for `sql.reserve()` and `sql.begin()`. */ + interface PoolConnectTraceContext { + pool: { + totalCount: number; + idleCount: number; + waitingCount: number; + maxSize: number; + }; + /** Set before `asyncStart` on success */ + client?: { + processID: number | undefined; + /** False when the acquisition had to open the connection */ + reused: boolean; + }; error?: Error; } /** Context of the `postgres:transaction` tracing channel, for `sql.begin()` and `sql.savepoint()`. */ interface TransactionTraceContext { - database: string; - user: string; + client: TraceClient; /** The mode string passed to `sql.begin()`; undefined for a savepoint */ options: string | undefined; /** The savepoint name as sent to the server; undefined for `sql.begin()` */ @@ -841,9 +861,17 @@ declare namespace postgres { error?: Error; } - /** Payload of the `postgres:connection:close` channel. */ - interface ConnectionCloseMessage { - connectionId: number; + /** Message of the `postgres:pool:release` channel. */ + interface PoolReleaseMessage { + client: { processID: number | undefined }; + /** The error the lease ended with */ + error: Error | undefined; + } + + /** Message of the `postgres:pool:remove` channel. */ + interface PoolRemoveMessage { + client: { processID: number | undefined }; + /** The error that closed the connection; undefined for a normal close */ error: Error | undefined; } From 99630efa965ee7fd93bb67ed9ee2460efd66e2b0 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:40:03 +0100 Subject: [PATCH 13/34] test: follow the new tracing payloads and cover the pool channels The existing tests read the node-postgres style fields. New tests cover postgres:pool:connect with the pool counts at start and reused false then true, a checkout that fails (refused connection, ended pool) with no client, postgres:pool:release for a reserved connection (once, with the checkout's process id) and for transactions that commit and that reject, a named prepared statement, and the pool channels staying silent without a subscriber. The workerd scenario reads the renamed connect channel. --- tests-startup/workerd-worker.js | 8 +- tests/tracing.js | 150 ++++++++++++++++++++++++++------ 2 files changed, 126 insertions(+), 32 deletions(-) diff --git a/tests-startup/workerd-worker.js b/tests-startup/workerd-worker.js index dbe0aacc..a06bf945 100644 --- a/tests-startup/workerd-worker.js +++ b/tests-startup/workerd-worker.js @@ -186,17 +186,17 @@ const scenarios = { const { AsyncLocalStorage } = await import('node:async_hooks') const storage = new AsyncLocalStorage() const query = dc.tracingChannel('postgres:query') - const connection = dc.tracingChannel('postgres:connection') + const connection = dc.tracingChannel('postgres:connect') equal([typeof dc.tracingChannel, typeof query.start.runStores, typeof query.start.bindStore], ['function', 'function', 'function']) const seen = [] const handlers = (name) => ({ start: () => seen.push(name + ':start:' + storage.getStore()), end: () => seen.push(name + ':end'), asyncStart: () => seen.push(name + ':asyncStart'), - asyncEnd: (context) => seen.push(name + ':asyncEnd:' + (context.query || context.serverAddress)), + asyncEnd: (context) => seen.push(name + ':asyncEnd:' + (context.query ? context.query.text : context.connection.host)), error: (context) => seen.push(name + ':error:' + context.error.message) }) - const subscribed = [[query, handlers('query')], [connection, handlers('connection')]] + const subscribed = [[query, handlers('query')], [connection, handlers('connect')]] query.start.bindStore(storage, () => 'span') subscribed.forEach(([channel, handler]) => channel.subscribe(handler)) const sql = client(port, { max: 1 }) @@ -209,7 +209,7 @@ const scenarios = { } const address = sql.options.host[0] equal(seen, [ - 'query:start:span', 'query:end', 'connection:start:span', 'connection:end', 'connection:asyncStart', 'connection:asyncEnd:' + address, + 'query:start:span', 'query:end', 'connect:start:span', 'connect:end', 'connect:asyncStart', 'connect:asyncEnd:' + address, 'query:asyncStart', 'query:asyncEnd:select 1 as x' ]) return { seen } diff --git a/tests/tracing.js b/tests/tracing.js index 80d683c4..9cf4f318 100644 --- a/tests/tracing.js +++ b/tests/tracing.js @@ -45,7 +45,7 @@ const plain = 'start end asyncStart asyncEnd' const failed = 'start end error asyncStart asyncEnd' const count = (events, stage, name = 'query') => of(events, name).filter(x => x.stage === stage).length const settled = (events, name = 'query') => JSON.stringify([count(events, 'start', name), count(events, 'asyncEnd', name)]) -const server = o => ({ address: o.path || o.host[0], port: o.path ? undefined : o.port[0] }) +const server = o => ({ host: o.path || o.host[0], port: o.path ? undefined : o.port[0] }) async function traced(names, fn) { const sql = postgres(options) @@ -61,26 +61,37 @@ async function traced(names, fn) { t('Tracing publishes a query in order with the context filled in as it is built', async() => { const sql = postgres(options) - const { events, stop } = collect('query', 'postgres:query:send') + const { events, stop } = collect('query', 'postgres:query:send', 'postgres:pool:remove') await sql`select ${ 5 }::int as x` - stop() await sql.end() + stop() const [done] = ended(events) const [send] = of(events, 'postgres:query:send') - const { address, port } = server(sql.options) + const [remove] = of(events, 'postgres:pool:remove') + const { host, port } = server(sql.options) return [ JSON.stringify([ - 'start end publish asyncStart asyncEnd', 'select $1::int as x', [5], 'SELECT', 1, - sql.options.database, sql.options.user, address, port, true, true + 'start end publish asyncStart asyncEnd', 'select $1::int as x', null, 'SELECT', 1, + sql.options.database, sql.options.user, host, port, false, true, true, false, true ]), JSON.stringify([ - events.map(x => x.stage).join(' '), done.query, done.values, done.result.command, done.result.count, - done.database, done.user, done.serverAddress, done.serverPort, typeof done.connectionId === 'number', - send.context.connectionId === done.connectionId + events.filter(x => x.name !== 'postgres:pool:remove').map(x => x.stage).join(' '), done.query.text, done.query.name || null, + done.result.command, done.result.count, done.client.database, done.client.user, done.client.host, done.client.port, done.client.ssl, + typeof done.client.processID === 'number', send.context.client === done.client, 'values' in done, + remove.context.client.processID === done.client.processID ]) ] }) +t('Tracing names a prepared statement and leaves an unnamed one without a name', async() => { + const events = await traced(['query'], async sql => { + await sql`select 1 as x` + await sql`select 2 as x`.simple() + }) + const [prepared, simple] = ended(events) + return [JSON.stringify([true, null]), JSON.stringify([typeof prepared.query.name === 'string', simple.query.name || null])] +}) + t('Tracing publishes error before asyncStart and keeps the error on the context', async() => { const events = await traced(['query'], sql => sql`select 1/0`.catch(() => undefined)) const [done] = ended(events) @@ -121,7 +132,7 @@ t('Tracing COPY streams settles each query once, when the copy is over', async() await new Promise(resolve => readable.on('end', resolve)) await sql`select 1` }) - const copies = ended(events).filter(x => /^copy/.test(x.query)) + const copies = ended(events).filter(x => /^copy/.test(x.query.text)) return [ JSON.stringify([4, 4, ['COPY', 'COPY']]), JSON.stringify([count(events, 'start'), count(events, 'asyncEnd'), copies.map(x => x.result.command)]) @@ -143,7 +154,7 @@ t('Tracing pipelined queries settle once each with their own context', async() = const events = await traced(['query'], sql => Promise.all([sql`select 1 as x`, sql`select 2 as x`, sql`select 3 as x`])) return [ JSON.stringify(['select 1 as x', 'select 2 as x', 'select 3 as x']), - JSON.stringify(ended(events).map(x => x.query)) + JSON.stringify(ended(events).map(x => x.query.text)) ] }) @@ -158,7 +169,7 @@ t('Tracing a query cancelled before it is sent settles with the cancellation and const result = await code stop() await sql.end() - const never = of(events, 'query').find(x => x.stage === 'asyncEnd' && x.context.query === undefined) + const never = of(events, 'query').find(x => x.stage === 'asyncEnd' && x.context.query.text === undefined) const own = events.filter(x => x.id === never.id) return [ JSON.stringify(['57014', failed]), @@ -190,7 +201,7 @@ t('Tracing a transaction publishes begin, its queries and a savepoint', async() ]), JSON.stringify([ sequence(events, 'transaction'), transaction.options, savepoint.savepoint, transaction.result, savepoint.result || null, - ended(events).map(x => x.query) + ended(events).map(x => x.query.text) ]) ] }) @@ -203,24 +214,27 @@ t('Tracing a rolled back transaction publishes the error', async() => { return [failed + '|stop', sequence(events, 'transaction') + '|' + done.error.message] }) -t('Tracing a connection publishes its attempt and the close', async() => { +t('Tracing a connection publishes its attempt and the removal', async() => { const sql = postgres(options) - const { events, stop } = collect('connection', 'postgres:connection:close') + const { events, stop } = collect('connect', 'postgres:pool:remove') await sql`select 1` await sql.end() stop() - const [done] = ended(events, 'connection') - const [close] = of(events, 'postgres:connection:close') - const { address, port } = server(sql.options) + const [done] = ended(events, 'connect') + const [remove] = of(events, 'postgres:pool:remove') + const { host, port } = server(sql.options) return [ - JSON.stringify([plain, true, sql.options.database, address, port, true, null]), - JSON.stringify([sequence(events, 'connection'), typeof done.connectionId === 'number', done.database, done.serverAddress, done.serverPort, - close.context.connectionId === done.connectionId, close.context.error || null]) + JSON.stringify([plain, [sql.options.database, host, port, sql.options.user, false], true, null]), + JSON.stringify([ + sequence(events, 'connect'), + [done.connection.database, done.connection.host, done.connection.port, done.connection.user, done.connection.ssl], + typeof remove.context.client.processID === 'number', remove.context.error || null + ]) ] }) t('Tracing a connection that fails to authenticate or to connect publishes the error', async() => { - const { events, stop } = collect('connection') + const { events, stop } = collect('connect') const codes = [] const wrong = postgres({ ...options, user: 'postgres_js_test_md5', pass: 'wrong' }) codes.push(await wrong`select 1`.catch(e => e.code)) @@ -229,7 +243,7 @@ t('Tracing a connection that fails to authenticate or to connect publishes the e stop() return [ JSON.stringify([['28P01', 'ECONNREFUSED'], failed + ' ' + failed, ['28P01', 'ECONNREFUSED']]), - JSON.stringify([codes, sequence(events, 'connection'), ended(events, 'connection').map(x => x.error.code)]) + JSON.stringify([codes, sequence(events, 'connect'), ended(events, 'connect').map(x => x.error.code)]) ] }) @@ -280,8 +294,8 @@ t('Tracing a transaction runs its function inside the transaction context', asyn t('Nothing is published when nothing subscribes', async() => { const published = [] const restore = [] - const names = ['query', 'connection', 'transaction'].flatMap(name => stages.map(stage => 'tracing:postgres:' + name + ':' + stage)) - .concat('postgres:query:send', 'postgres:connection:close') + const names = ['query', 'connect', 'transaction', 'pool:connect'].flatMap(name => stages.map(stage => 'tracing:postgres:' + name + ':' + stage)) + .concat('postgres:query:send', 'postgres:pool:release', 'postgres:pool:remove') for (const name of names) { const channel = dc.channel(name) for (const method of ['publish', 'runStores']) { @@ -301,6 +315,9 @@ t('Nothing is published when nothing subscribes', async() => { await sql`select 2 as x` await sql.savepoint(sql => sql`select 3 as x`) }) + const reserved = await sql.reserve() + await reserved`select 4 as x` + reserved.release() } finally { await sql.end() restore.forEach(x => x()) @@ -310,12 +327,12 @@ t('Nothing is published when nothing subscribes', async() => { }) t('Tracing contexts have no result or error key until one is set', async() => { - const events = await traced(['query', 'connection', 'transaction'], sql => sql.begin(async sql => { + const events = await traced(['query', 'connect', 'transaction', 'pool:connect'], sql => sql.begin(async sql => { await sql.savepoint(sql => sql`select 1 as x`) })) const early = events.filter(x => x.stage === 'start' || x.stage === 'end') return [ - JSON.stringify([['connection', 'query', 'transaction'], 0]), + JSON.stringify([['connect', 'pool:connect', 'query', 'transaction'], 0]), JSON.stringify([[...new Set(early.map(x => x.name))].sort(), early.filter(x => 'result' in x.context || 'error' in x.context).length]) ] }) @@ -357,7 +374,7 @@ t('Tracing a retried query settles once and is sent again', async() => { await sql`drop table tracing_retry` stop() await sql.end() - const sends = of(events, 'postgres:query:send').filter(x => x.context.query === 'select name from tracing_retry').length + const sends = of(events, 'postgres:query:send').filter(x => x.context.query.text === 'select name from tracing_retry').length return [JSON.stringify([1, 7, 7, 3]), JSON.stringify([row.name, count(events, 'start'), count(events, 'asyncEnd'), sends])] }) @@ -421,3 +438,80 @@ t('Tracing queries rejected because the pool is ending settle once', async() => stop() return [JSON.stringify([2, 2]), settled(events)] }) + +t('Tracing a pool checkout reports the pool at start and whether the connection was reused', async() => { + const sql = postgres(options) + const { events, stop } = collect('pool:connect') + const first = await sql.reserve() + first.release() + const second = await sql.reserve() + second.release() + stop() + await sql.end() + const starts = of(events, 'pool:connect').filter(x => x.stage === 'start').map(x => x.context.pool) + const [one, two] = ended(events, 'pool:connect') + return [ + JSON.stringify([ + plain + ' ' + plain, + [{ totalCount: 0, idleCount: 0, waitingCount: 0, maxSize: 1 }, { totalCount: 1, idleCount: 1, waitingCount: 0, maxSize: 1 }], + [false, true], true + ]), + JSON.stringify([ + sequence(events, 'pool:connect'), starts, [one.client.reused, two.client.reused], + typeof one.client.processID === 'number' && one.client.processID === two.client.processID + ]) + ] +}) + +t('Tracing a pool checkout that fails publishes the error and no client', async() => { + const { events, stop } = collect('pool:connect') + const refused = postgres({ ...options, host: '127.0.0.1', port: 33333 }) + const codes = [await refused.reserve().catch(e => e.code)] + const ended_ = postgres(options) + await ended_.end() + codes.push(await ended_.reserve().catch(e => e.code)) + stop() + const done = ended(events, 'pool:connect') + return [ + JSON.stringify([['ECONNREFUSED', 'CONNECTION_ENDED'], failed + ' ' + failed, [false, false], ['ECONNREFUSED', 'CONNECTION_ENDED']]), + JSON.stringify([codes, sequence(events, 'pool:connect'), done.map(x => 'client' in x), done.map(x => x.error.code)]) + ] +}) + +t('Tracing a release publishes once, with the connection of the checkout', async() => { + const sql = postgres(options) + const { events, stop } = collect('pool:connect', 'postgres:pool:release') + const reserved = await sql.reserve() + reserved.release() + reserved.release() + stop() + await sql.end() + const releases = of(events, 'postgres:pool:release') + const [checkout] = ended(events, 'pool:connect') + return [ + JSON.stringify([1, true, null]), + JSON.stringify([releases.length, releases[0].context.client.processID === checkout.client.processID, releases[0].context.error || null]) + ] +}) + +t('Tracing a transaction publishes its release with the error that ended it', async() => { + const sql = postgres(options) + const { events, stop } = collect('pool:connect', 'postgres:pool:release', 'transaction') + await sql.begin(async sql => { + await sql`select 1` + }) + await sql.begin(() => { + throw new Error('stop') + }).catch(() => undefined) + stop() + await sql.end() + const releases = of(events, 'postgres:pool:release').map(x => x.context) + const checkouts = ended(events, 'pool:connect') + return [ + JSON.stringify([2, [null, 'stop'], [true, true], 2]), + JSON.stringify([ + releases.length, releases.map(x => x.error ? x.error.message : null), + releases.map((x, i) => x.client.processID === checkouts[i].client.processID), checkouts.length + ]) + ] +}) From 55382359d4bcae3951e15f3e05fcc724147bf63f Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:40:26 +0100 Subject: [PATCH 14/34] docs: describe the node-postgres style tracing payloads and pool channels Lists the tracing and plain channels, shows how they fit together, and gives one field table per channel with when each field is set. --- README.md | 2 +- docs/api.md | 134 +++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 102 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 5b637775..d9cb665b 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ With `prepare: false`, parameterized queries can pay for an extra network round ### New features - **See pool pressure.** [`sql.stats()`](docs/api.md#pool-stats) reports busy and idle connections and waiting queries. -- **Trace queries natively.** [`postgres:query`, `postgres:connection` and `postgres:transaction`](docs/api.md#tracing-channels) are `diagnostics_channel` tracing channels, published only when something subscribes. +- **Trace queries natively.** [`postgres:query`, `postgres:connect`, `postgres:pool:connect` and `postgres:transaction`](docs/api.md#tracing-channels) are `diagnostics_channel` tracing channels, published only when something subscribes. - **Handle serverless suspension.** Expired idle connections are replaced on resume; the [Vercel Fluid Compute adapter](docs/api.md#vercel-fluid-compute) keeps instances alive until idle connections close. - **Automatic cleanup.** [`await using`](docs/api.md#teardown--cleanup) closes a pool and `using` releases a reserved connection when its scope ends. Requires Node.js 24+ or TypeScript 5.2+. - **Cloudflare Workers fixes.** On [Workers](docs/api.md#cloudflare-workers-support), `COPY TO` streams and `subscribe()` pause and resume the socket ([#975](https://github.com/porsager/postgres/issues/975)), `ssl: 'prefer'` falls back to plaintext when the server declines TLS, and a certificate from a private CA fails at once with [`TLS_CLOSED`](docs/api.md#tls_closed) instead of a timeout ([#1213](https://github.com/porsager/postgres/issues/1213)). diff --git a/docs/api.md b/docs/api.md index 8072ca12..b4b6732f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1512,37 +1512,105 @@ After release, queries through that reserved handle reject with [`CONNECTION_REL ## Tracing channels -The driver publishes `diagnostics_channel` events for queries, connections and transactions, so an APM or OpenTelemetry integration can subscribe without patching the driver. Nothing is built or published while no one subscribes: a query costs one `hasSubscribers` check. - -| Channel | Type | Covers | -|---|---|---| -| `postgres:query` | `TracingChannel` | One span per query, from the call that runs it until the server answers or the query fails. | -| `postgres:connection` | `TracingChannel` | One span per connection acquisition: socket, TLS, authentication, startup and type fetching, up to the connection serving queries. Retries and other hosts stay inside the same span. | -| `postgres:transaction` | `TracingChannel` | `sql.begin()` and, nested in it, each `sql.savepoint()`. | -| `postgres:query:send` | `Channel` | The query's context again, each time it is written to a connection. Published only for queries that had a `postgres:query` subscriber when they started. | -| `postgres:connection:close` | `Channel` | `{ connectionId, error }` whenever a connection closes, including a failed connection attempt. `error` is undefined for a normal close. | - -The `TracingChannel`s publish in the order Node's `tracePromise` does: `start` and `end` in the caller's synchronous code, then for a failure `error`, and for both outcomes `asyncStart` and `asyncEnd`, with `context.result` or `context.error` set first. `start` is published with `runStores`, so a store bound to it, such as an `AsyncLocalStorage` span context, is current in the code that starts the query. Every query publishes `asyncStart` and `asyncEnd` exactly once. That includes cursors read with `for await` or a callback, `forEach`, `COPY` streams (once the copy is over), `describe()`, `simple()`, `unsafe()`, `file()`, pipelined queries, retried queries and queries cancelled before they were sent. The driver's own startup queries (type fetching, `target_session_attrs`) are not published; they belong to the connection span. - -The context objects are the `QueryTraceContext`, `ConnectionTraceContext` and `TransactionTraceContext` types. - -| Field | Channel | Set | -|---|---|---| -| `database`, `user` | all three | at `start` | -| `serverAddress`, `serverPort` | query, connection | at `start` when one host is configured; otherwise when the query is written, or when each connection attempt opens its socket. A Unix socket path is the address, with no port. | -| `query` | query | when the query is written: SQL text with `$n` placeholders | -| `values` | query | when the query is written: the parameters before they are serialized, raw | -| `connectionId` | query, connection | when the query is written (query); at `start` (connection). It is the `id` that `onclose` and `onidle` report. | -| `result` | query | before `asyncStart`: the `Result` with `command` and `count`; for `describe()` the statement; for a `COPY` stream the `Result` of the finished copy, not the stream | -| `result` | transaction | before `asyncStart` for `sql.begin()`: `{ committed }`, false when the transaction was prepared | -| `error` | all three | before `error` | -| `options` | transaction | at `start`: the mode string given to `sql.begin()` | -| `savepoint` | transaction | at `start` for `sql.savepoint()`: the savepoint name as sent, such as `s0_name` | -| `prepared` | transaction | when `sql.prepare(name)` is called | - -The `result` and `error` keys do not exist on a context until they are set, so a subscriber can tell a finished call from a started one by `'result' in context || 'error' in context`. The text and parameters are known only after the driver has serialized the query for a connection, so they are not in the context at `start`. Read them at `asyncEnd` or `error`, or rename the span then. A query written again after a retry publishes `postgres:query:send` again, with the same context. The time between `start` and the first `postgres:query:send` is the wait for a connection, and between that and `asyncEnd` the time on the server. - -Parameters are included as they are. Decide in your subscriber what to record, since values can hold personal data. +The driver publishes `diagnostics_channel` events for queries, connections, pool checkouts and transactions, so an APM or OpenTelemetry integration can subscribe without patching the driver. The names and fields follow the tracing channels of node-postgres, so a subscriber written for one driver reads the other with few changes. Nothing is built or published while no one subscribes: a query costs one `hasSubscribers` check. + +### TracingChannels + +These publish `start`, `end`, `asyncStart`, `asyncEnd` and `error`. + +| Channel | Fires for | +|---|---| +| `postgres:query` | Each query: from the call that runs it until the server answers or the query fails | +| `postgres:connect` | Each connection acquisition: socket, TLS, authentication, startup and type fetching, up to the connection serving queries. Retries and other hosts stay inside one span. | +| `postgres:pool:connect` | Each `sql.reserve()` and `sql.begin()`: acquiring a connection for exclusive use. Plain pooled queries do not publish it; `postgres:query:send` shows their wait. | +| `postgres:transaction` | `sql.begin()` and, nested in it, each `sql.savepoint()` | + +### Plain channels + +These publish one message. Subscribe with `dc.channel(name).subscribe(callback)`. + +| Channel | Fires for | +|---|---| +| `postgres:query:send` | A query is written to a connection. It carries the query's context, and is published only for queries that had a `postgres:query` subscriber when they started. | +| `postgres:pool:release` | A reserved connection or a transaction gives its connection back | +| `postgres:pool:remove` | A connection closes, including a failed connection attempt | + +### How they fit together + +```text +postgres:pool:connect sql.reserve() or sql.begin() acquires a connection + └─ postgres:connect only if the acquisition had to open the connection +postgres:transaction sql.begin() runs its function + └─ postgres:query each statement, BEGIN and COMMIT included + └─ postgres:query:send the statement is written to the connection +postgres:pool:release the connection goes back to the pool +postgres:pool:remove later, when the connection closes +``` + +A plain query publishes `postgres:query` and `postgres:query:send`, and `postgres:connect` when its connection has to be opened. `postgres:query:send` is published again when a query is retried. + +The `TracingChannel`s publish in the order Node's `tracePromise` does: `start` and `end` in the caller's synchronous code, then for a failure `error`, and for both outcomes `asyncStart` and `asyncEnd`, with `context.result` or `context.error` set first. `start` is published with `runStores`, so a store bound to it, such as an `AsyncLocalStorage` span context, is current in the code that starts the call. Every query publishes `asyncStart` and `asyncEnd` exactly once. That includes cursors read with `for await` or a callback, `forEach`, `COPY` streams (once the copy is over), `describe()`, `simple()`, `unsafe()`, `file()`, pipelined queries, retried queries and queries cancelled before they were sent. The driver's own startup queries (type fetching, `target_session_attrs`) are not published; they belong to the connect span. + +The `result` and `error` keys do not exist on a context until they are set, so a subscriber can tell a finished call from a started one by `'result' in context || 'error' in context`. + +## Context payloads + +The types are `QueryTraceContext`, `ConnectTraceContext`, `PoolConnectTraceContext`, `TransactionTraceContext`, `PoolReleaseMessage` and `PoolRemoveMessage`. No payload contains parameter values. + +### postgres:query + +| Field | Set | +|---|---| +| `query.text` | when the query is written to a connection: SQL with `$n` placeholders | +| `query.name` | when the query is written: the prepared statement name, undefined for an unnamed statement | +| `client.database`, `client.user` | at `start` | +| `client.host`, `client.port` | at `start` when one host is configured, otherwise when the query is written. For a Unix socket the path is the host and there is no port. | +| `client.ssl` | at `start` from the `ssl` option; when the query is written, whether the connection is encrypted | +| `client.processID` | when the query is written: the backend process id, which `postgres:pool:connect` and `postgres:pool:remove` also report | +| `result` | before `asyncStart`: the `Result` with `command` and `count`; for `describe()` the statement; for a `COPY` stream the `Result` of the finished copy, not the stream | +| `error` | before `error` | + +The text is known only after the driver has serialized the query for a connection, so read it at `asyncEnd` or `error`, or rename the span then. The time between `start` and `postgres:query:send` is the wait for a connection, and between that and `asyncEnd` the time on the server. + +### postgres:connect + +| Field | Set | +|---|---| +| `connection.database`, `connection.user` | at `start` | +| `connection.host`, `connection.port` | at `start` when one host is configured; updated as each attempt opens its socket | +| `connection.ssl` | at `start` from the `ssl` option; before `asyncStart`, whether the connection is encrypted | +| `error` | before `error`: the failure that ends the acquisition | + +### postgres:pool:connect + +| Field | Set | +|---|---| +| `pool.totalCount`, `pool.idleCount`, `pool.waitingCount` | at `start`, as `sql.stats()` reports them | +| `pool.maxSize` | at `start`: the `max` option | +| `client.processID` | before `asyncStart` on success | +| `client.reused` | before `asyncStart` on success: false when the acquisition had to open the connection | +| `error` | before `error`: a connection error, or `CONNECTION_ENDED` after `sql.end()` | + +### postgres:transaction + +| Field | Set | +|---|---| +| `client` | as in `postgres:query`; the connection fields are filled in once the transaction has its connection | +| `options` | at `start`: the mode string given to `sql.begin()`; undefined for a savepoint | +| `savepoint` | at `start` for `sql.savepoint()`: the savepoint name as sent, such as `s0_name` | +| `prepared` | when `sql.prepare(name)` is called | +| `result` | before `asyncStart` for `sql.begin()`: `{ committed }`, false when the transaction was prepared | +| `error` | before `error` | + +### postgres:pool:release + +`{ client: { processID }, error }`. `error` is the error the lease ended with: the failure that ended a transaction before its connection was released, or the error that closed the connection. It is undefined after a normal release. A commit the server answered with a rollback is reported after the release, so the release carries no error then. + +### postgres:pool:remove + +`{ client: { processID }, error }`. `error` is undefined for a normal close and is not part of the node-postgres message. + +## Example ```js import dc from 'node:diagnostics_channel' @@ -1556,10 +1624,10 @@ channel.subscribe({ end() {}, asyncStart() {}, asyncEnd(context) { - console.log(context.query, context.result?.count, performance.now() - context.startedAt) + console.log(context.query.text, context.result?.count, performance.now() - context.startedAt) }, error(context) { - console.error(context.query, context.error) + console.error(context.query.text, context.error) } }) ``` From 4301ec2264e6906c89f3a15adc69cb2d68b79a52 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:40:26 +0100 Subject: [PATCH 15/34] docs: refresh the diff since 3.4.9 --- docs/changes-since-3.4.9.diff | 403 +++++++++++++++++++++------------- 1 file changed, 248 insertions(+), 155 deletions(-) diff --git a/docs/changes-since-3.4.9.diff b/docs/changes-since-3.4.9.diff index 4bb09010..dcd23db4 100644 --- a/docs/changes-since-3.4.9.diff +++ b/docs/changes-since-3.4.9.diff @@ -1,8 +1,8 @@ diff --git a/src/connection.js b/src/connection.js -index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08abe4a95a 100644 +index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa7c1bb839 100644 --- a/src/connection.js +++ b/src/connection.js -@@ -4,24 +4,42 @@ import crypto from 'crypto' +@@ -4,24 +4,53 @@ import crypto from 'crypto' import Stream from 'stream' import { performance } from 'perf_hooks' @@ -16,9 +16,21 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 +import { SSLRequest, tlsConfig, cancelRequest } from './transport.js' import { Query, CLOSE } from './query.js' import b from './bytes.js' -+import { connectionChannel, watched, run, resolved, rejected, connectionContext, connectionAttempt, querySent, connectionClosed } from './tracing.js' - +- -export default Connection ++import { ++ connectChannel, ++ watched, ++ run, ++ resolved, ++ rejected, ++ connectContext, ++ connectAttempt, ++ connectSecured, ++ querySent, ++ poolRemoved ++} from './tracing.js' ++ +export { Connection } let uid = 1 @@ -49,7 +61,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 const retryRoutines = new Set([ 'FetchPreparedStatement', 'RevalidateCachedQuery', -@@ -49,7 +67,7 @@ const errorFields = { +@@ -49,7 +78,7 @@ const errorFields = { 82 : 'routine' // R } @@ -58,7 +70,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 const { sslnegotiation, ssl, -@@ -72,12 +90,20 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -72,12 +101,20 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose const sent = Queue() , id = uid++ , backend = { pid: null, secret: null } @@ -83,7 +95,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 , errorResponse = null , result = new Result() , incoming = Buffer.alloc(0) -@@ -86,82 +112,464 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -86,82 +123,472 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose , statements = {} , statementId = Math.random().toString(36).slice(2) , statementCount = 1 @@ -92,6 +104,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 , hostIndex = 0 - , retries = 0 + , serverIndex = 0 ++ , secured = false , length = 0 - , delay = 0 , rows = 0 @@ -233,6 +246,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 + get active() { return query ? 1 : 0 }, + get pipelined() { return sent.length }, + get accepting() { return accepting() }, ++ get session() { return session() }, + get copying() { return stream !== null }, + get copyHeld() { return stream !== null && !stream.writableEnded }, + get acquiring() { return acquisition && !acquisition.owner.cancelled ? 1 : 0 }, @@ -304,6 +318,10 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 + return options.path || host[serverIndex] + } + ++ function session() { ++ return { host: serverAddress(), port: serverPort(), ssl: secured, processID: backend.pid } ++ } ++ + function serverPort() { + return options.path ? undefined : port[serverIndex] + } @@ -314,11 +332,11 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 + + function acquire(owner) { + acquisition = { owner, ending: false, pass: 'standby', hostsTried: 0, attempting: null, mismatches: [], lastError: null, trace: undefined } -+ if (!watched(connectionChannel)) ++ if (!watched(connectChannel)) + return startAcquisition() + -+ acquisition.trace = connectionContext(id, options) -+ run(connectionChannel, acquisition.trace, startAcquisition) ++ acquisition.trace = connectContext(options) ++ run(connectChannel, acquisition.trace, startAcquisition) + } + + function startAcquisition() { @@ -365,7 +383,8 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 + listen(created) + socket = created + serverIndex = hostIndex -+ acquisition.trace && connectionAttempt(acquisition.trace, serverAddress(), serverPort()) ++ secured = false ++ acquisition.trace && connectAttempt(acquisition.trace, serverAddress(), serverPort()) + acquisition.attempting = options.socket ? 'custom socket' : options.path || host[hostIndex] + ':' + port[hostIndex] + + if (options.socket) @@ -404,6 +423,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 + const config = tlsConfig(options, raw) + raw.removeAllListeners() + socket = tls.connect(config) ++ secured = true + socket.on('secureConnect', () => dispatch('secure')) + listen(socket) + } catch (err) { @@ -468,7 +488,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 + + delay = backoffMs() + } - } ++ } + + clearTimeout(attemptTimer) + attemptTimer = null @@ -515,10 +535,10 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 + acquisition = null + endWaiters = [] + transition(Phase.Closed) -+ a && a.trace && rejected(connectionChannel, a.trace, err) ++ a && a.trace && rejected(connectChannel, a.trace, err) + a && queryError(a.owner, err) + waiters.forEach(resolve => resolve()) -+ connectionClosed(id, reported) ++ poolRemoved(a ? undefined : backend.pid, reported) + onclose(connection, reported) + } + @@ -541,15 +561,15 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 + ms && (closeTimer = clampedTimeout(() => (closeTimer = null, dispatch('closeTimeout')), ms)) + } else { + socket.destroy() -+ } -+ } -+ + } + } + + function handoff() { + const { owner, ending, trace } = acquisition + transition(Phase.Ready) + clearAcquisitionTimers() + acquisition = null -+ trace && resolved(connectionChannel, trace) ++ trace && (connectSecured(trace, secured), resolved(connectChannel, trace)) + options.shared.retries = 0 + lifeTimer.start() + @@ -561,8 +581,8 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 + } + + onopen(connection, owner.cancelled ? undefined : owner) - } - ++ } ++ function execute(q) { - if (terminated) - return queryError(q, Errors.connection('CONNECTION_DESTROYED', options)) @@ -586,7 +606,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 try { q.state = backend -@@ -169,19 +577,35 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -169,19 +596,35 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose ? sent.push(q) : (query = q, query.active = true) @@ -630,7 +650,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 function toBuffer(q) { if (q.parameters.length >= 65534) throw Errors.generic('MAX_PARAMETERS_EXCEEDED', 'Max number of parameters (65534) exceeded') -@@ -206,7 +630,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -206,7 +649,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose function prepared(q) { return Buffer.concat([ @@ -639,7 +659,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 q.cursorFn ? Execute('', q.cursorRows) : ExecuteUnnamed -@@ -234,13 +658,17 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -234,13 +677,17 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose q.signature = q.prepare && types + string q.onlyDescribe && (delete statements[q.signature]) q.parameters = q.parameters || parameters @@ -655,11 +675,11 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 - typeof options.debug === 'function' && options.debug(id, string, parameters, types) + typeof options.debug === 'function' && callback(options.debug, id, string, parameters, types) -+ q.trace && querySent(q, id, serverAddress(), serverPort()) ++ q.trace && querySent(q, session()) } function write(x, fn) { -@@ -252,52 +680,12 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -252,52 +699,12 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function nextWrite(fn) { @@ -713,7 +733,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 function data(x) { if (incomings) { incomings.push(x) -@@ -312,6 +700,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -312,6 +719,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose ? x : Buffer.concat([incoming, x], incoming.length + x.length) @@ -721,7 +741,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 while (incoming.length > 4) { length = incoming.readUInt32BE(1) if (length >= incoming.length) { -@@ -323,74 +712,34 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -323,74 +731,34 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose try { handle(incoming.subarray(0, length + 1)) } catch (e) { @@ -810,7 +830,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 } function queryError(query, err) { -@@ -401,7 +750,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -401,7 +769,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose err = new Error(err) 'query' in err || 'parameters' in err || Object.defineProperties(err, { @@ -819,7 +839,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 query: { value: query.string, enumerable: options.debug }, parameters: { value: query.parameters, enumerable: options.debug }, args: { value: query.args, enumerable: options.debug }, -@@ -410,51 +759,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -410,51 +778,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose query.reject(err) } @@ -879,7 +899,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 } /* Handlers */ -@@ -495,6 +807,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -495,6 +826,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose let column let value @@ -888,7 +908,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 const row = query.isRaw ? new Array(query.statement.columns.length) : {} for (let i = 0; i < query.statement.columns.length; i++) { column = query.statement.columns[i] -@@ -514,13 +828,13 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -514,13 +847,13 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose query.isRaw ? (row[i] = query.isRaw === true ? value @@ -906,7 +926,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 } function ParameterStatus(x) { -@@ -528,19 +842,25 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -528,19 +861,25 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose backendParameters[k] = v if (options.parameters[k] !== v) { options.parameters[k] = v @@ -934,7 +954,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 query.resolve(results || result) } } else if (errorResponse) { -@@ -549,42 +869,34 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -549,42 +888,34 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose query = results = errorResponse = null result = new Result() @@ -999,7 +1019,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 } function CommandComplete(x) { -@@ -602,7 +914,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -602,7 +933,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose final && (final(), final = null) @@ -1008,7 +1028,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 return errored(Errors.generic('UNSAFE_TRANSACTION', 'Only use sql.begin, sql.reserved or max: 1')) if (query.options.simple) -@@ -654,7 +966,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -654,7 +985,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose const number = x.readUInt16BE(index + 4) const type = x.readUInt32BE(index + 6) query.statement.columns[i] = { @@ -1017,7 +1037,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 ? transform.column.from(x.toString('utf8', start, index - 1)) : x.toString('utf8', start, index - 1), parser: parsers[type], -@@ -670,8 +982,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -670,8 +1001,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose return (query.resolve(query.statement), write(Sync)) } @@ -1029,7 +1049,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 type === 3 ? AuthenticationCleartextPassword : type === 5 ? AuthenticationMD5Password : type === 10 ? SASL : -@@ -679,18 +992,20 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -679,18 +1011,20 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose type === 12 ? SASLFinal : type !== 0 ? UnknownAuth : noop @@ -1053,7 +1073,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 const payload = 'md5' + ( await md5( Buffer.concat([ -@@ -699,40 +1014,42 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -699,40 +1033,42 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose ]) ) ) @@ -1110,7 +1130,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 write( b().p().str(payload).end() ) -@@ -742,8 +1059,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -742,8 +1078,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose if (x.toString('utf8', 9).split(b.N, 1)[0].slice(2) === serverSignature) return /* c8 ignore next 5 */ @@ -1120,7 +1140,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 } function Pass() { -@@ -765,48 +1081,47 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -765,48 +1100,47 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose backend.secret = x.readUInt32BE(9) } @@ -1192,7 +1212,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 } function ErrorResponse(x) { -@@ -814,12 +1129,11 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -814,12 +1148,11 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose (query.cursorFn || query.describeFirst) && write(Sync) errorResponse = Errors.postgres(parseError(x)) } else { @@ -1206,7 +1226,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 q.retried = error execute(q) } -@@ -830,22 +1144,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -830,22 +1163,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose let index = 9 while (x[index++] !== 0); @@ -1240,7 +1260,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 } } -@@ -862,7 +1181,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -862,7 +1200,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose }, destroy(error, callback) { callback(error) @@ -1249,7 +1269,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 stream = null }, final(callback) { -@@ -872,27 +1191,29 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -872,27 +1210,29 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } }) query.resolve(stream) @@ -1282,7 +1302,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 stream = null }, final(callback) { -@@ -901,22 +1222,36 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -901,22 +1241,36 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } }) query.resolve(stream) @@ -1322,7 +1342,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 } /* c8 ignore next 3 */ -@@ -945,20 +1280,21 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -945,20 +1299,21 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } /* Messages */ @@ -1351,7 +1371,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 prev = b.i b.inc(4).str(x).i32(b.i - prev - 4, prev) -@@ -966,7 +1302,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -966,7 +1321,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose b.i16(0) @@ -1380,7 +1400,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 } function Parse(str, parameters, types, name = '') { -@@ -994,7 +1350,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -994,7 +1369,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function StartupMessage() { @@ -1389,7 +1409,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 Object.entries(Object.assign({ user, database, -@@ -1019,6 +1375,33 @@ function parseError(x) { +@@ -1019,6 +1394,33 @@ function parseError(x) { return error } @@ -1423,7 +1443,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..ca377594b31e766e39babed8fc568a08 function md5(x) { return crypto.createHash('md5').update(x).digest('hex') } -@@ -1042,21 +1425,44 @@ function xor(a, b) { +@@ -1042,21 +1444,44 @@ function xor(a, b) { function timer(fn, seconds) { seconds = typeof seconds === 'function' ? seconds() : seconds if (!seconds) @@ -1509,7 +1529,7 @@ index 0ff83c428bec774e11417b8e1f033dc3305c18cf..023c3ba1bd113e6f5508ab079efcf220 code: x, errno: x, diff --git a/src/index.js b/src/index.js -index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f7f51a399 100644 +index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d14b46f3c 100644 --- a/src/index.js +++ b/src/index.js @@ -3,10 +3,12 @@ import fs from 'fs' @@ -1525,7 +1545,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f toPascal, pascal, toCamel, -@@ -18,12 +20,17 @@ import { +@@ -18,12 +20,28 @@ import { fromKebab } from './types.js' @@ -1539,13 +1559,24 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f +import { Errors, PostgresError, callback } from './errors.js' import Subscribe from './subscribe.js' import largeObject from './large.js' -+import { transactionChannel, watched, traceAsync, transactionContext, transactionCommitted } from './tracing.js' ++import { ++ transactionChannel, ++ poolConnectChannel, ++ watched, ++ traceAsync, ++ transactionContext, ++ transactionSession, ++ transactionCommitted, ++ poolContext, ++ poolAcquired, ++ poolReleased ++} from './tracing.js' + +const Lease = { Active: 0, Settling: 1, Released: 2, Ended: 3, Closed: 4 } Object.assign(Postgres, { PostgresError, -@@ -51,6 +58,7 @@ function Postgres(a, b) { +@@ -51,6 +69,7 @@ function Postgres(a, b) { , subscribe = options.no_subscribe || Subscribe(Postgres, { ...options }) let ending = false @@ -1553,7 +1584,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f const queries = Queue() , connecting = Queue() -@@ -62,7 +70,7 @@ function Postgres(a, b) { +@@ -62,7 +81,7 @@ function Postgres(a, b) { , full = Queue() , queues = { connecting, reserved, closed, ended, open, busy, full } @@ -1562,7 +1593,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f const sql = Sql(handler) -@@ -75,16 +83,19 @@ function Postgres(a, b) { +@@ -75,16 +94,19 @@ function Postgres(a, b) { PostgresError, options, reserve, @@ -1583,7 +1614,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f Object.entries(options.types).reduce((acc, [name, type]) => { acc[name] = (x) => new Parameter(x, type.to) -@@ -128,7 +139,7 @@ function Postgres(a, b) { +@@ -128,7 +150,7 @@ function Postgres(a, b) { function file(path, args = [], options = {}) { arguments.length === 2 && !Array.isArray(args) && (options = args, args = []) @@ -1592,7 +1623,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f fs.readFile(path, 'utf8', (err, string) => { if (err) return query.reject(err) -@@ -136,7 +147,9 @@ function Postgres(a, b) { +@@ -136,7 +158,9 @@ function Postgres(a, b) { query.strings = [string] handler(query) }) @@ -1603,7 +1634,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f ...options, simple: 'simple' in options ? options.simple : args.length === 0 }) -@@ -144,6 +157,56 @@ function Postgres(a, b) { +@@ -144,6 +168,56 @@ function Postgres(a, b) { } } @@ -1660,7 +1691,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f async function listen(name, fn, onlisten) { const listener = { fn, onlisten } -@@ -153,6 +216,7 @@ function Postgres(a, b) { +@@ -153,6 +227,7 @@ function Postgres(a, b) { idle_timeout: null, max_lifetime: null, fetch_types: false, @@ -1668,7 +1699,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f onclose() { Object.entries(listen.channels).forEach(([name, { listeners }]) => { delete listen.channels[name] -@@ -160,7 +224,7 @@ function Postgres(a, b) { +@@ -160,7 +235,7 @@ function Postgres(a, b) { }) }, onnotify(c, x) { @@ -1677,7 +1708,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f } })) -@@ -170,7 +234,7 @@ function Postgres(a, b) { +@@ -170,7 +245,7 @@ function Postgres(a, b) { if (exists) { channels[name].listeners.push(listener) const result = await channels[name].result @@ -1686,7 +1717,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f return { state: result.state, unlisten } } -@@ -178,7 +242,7 @@ function Postgres(a, b) { +@@ -178,7 +253,7 @@ function Postgres(a, b) { sql.unsafe('"' + name.replace(/"/g, '""') + '"') }`, listeners: [listener] } const result = await channels[name].result @@ -1695,7 +1726,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f return { state: result.state, unlisten } async function unlisten() { -@@ -200,62 +264,155 @@ function Postgres(a, b) { +@@ -200,62 +275,170 @@ function Postgres(a, b) { return await sql`select pg_notify(${ channel }, ${ '' + payload })` } @@ -1708,11 +1739,20 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f - queries.push(query) - closed.length && connect(closed.shift(), query) - }) -+ async function lease(auto) { ++ function lease(auto) { ++ if (!watched(poolConnectChannel)) ++ return acquire(auto) ++ ++ const trace = poolContext(stats()) ++ return traceAsync(poolConnectChannel, trace, () => acquire(auto, trace)) ++ } ++ ++ async function acquire(auto, trace) { + if (ending) + throw Errors.connection('CONNECTION_ENDED', options) + -+ const l = { state: null, connection: null, error: null, onclose: null, queue: Queue() } ++ const l = { state: null, connection: null, error: null, outcome: undefined, onclose: null, queue: Queue() } ++ let reused = true + const idle = takeOpen() + idle + ? own(idle, l) @@ -1721,11 +1761,12 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f + : await new Promise((resolve, reject) => { + const query = { reserve: c => resolve(own(c, l)), reject } + closed.length -+ ? connect(closed.shift(), query) ++ ? (reused = false, connect(closed.shift(), query)) + : allHeldByCopy() + ? reject(copyInProgress()) + : queries.push(query) + }) ++ trace && poolAcquired(trace, l.connection.session.processID, reused) + return l + } + @@ -1751,7 +1792,12 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f + : (move(c, full), q.queue = l.queue, l.queue.push(q)) + : q.reject(leaseError(l)) + } -+ + +- move(c, reserved) +- c.reserved = () => queue.length +- ? c.execute(queue.shift()) +- : move(c, reserved) +- c.reserved.release = true + function leaseError(l) { + return l.state === Lease.Closed + ? Errors.connection('CONNECTION_CLOSED', options) @@ -1759,20 +1805,17 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f + ? Errors.generic('CONNECTION_RELEASED', 'Reserved connection used after release()') + : Errors.generic('TRANSACTION_ENDED', 'Transaction handle used after its transaction ended') + } - -- move(c, reserved) -- c.reserved = () => queue.length -- ? c.execute(queue.shift()) -- : move(c, reserved) -- c.reserved.release = true ++ + function stop(l, state) { + if (l.state === Lease.Released || l.state === Lease.Ended) + return null - ++ ++ const lost = l.state === Lease.Closed + l.state = state + while (l.queue.length) + l.queue.shift().reject(leaseError(l)) + ++ poolReleased(l.connection, lost ? l.error : l.outcome) + const c = l.connection + if (c.owner !== l) + return null @@ -1780,7 +1823,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f + c.owner = null + return c + } -+ + + async function reserve() { + const l = await lease(false) const sql = Sql(handler) @@ -1813,12 +1856,13 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f + if (!watched(transactionChannel)) + return transact(mode, fn) + -+ const trace = transactionContext(options, mode) ++ const trace = transactionContext(options, undefined, mode) + return traceAsync(transactionChannel, trace, () => transact(mode, fn, trace)) + } + + async function transact(options, fn, trace) { + const l = await lease(true) ++ trace && transactionSession(trace, l.connection.session) let savepoints = 0 - , connection , prepare = null @@ -1837,6 +1881,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f + + async function transaction() { + await Sql(handler).unsafe('begin ' + options.replace(/[^a-z ]/ig, ''), []).execute().catch(e => { ++ l.outcome = e + e instanceof PostgresError && release() + throw e + }) @@ -1849,6 +1894,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f + try { + result = await statement(Sql(q => handler(q, Lease.Settling))) + } catch (e) { ++ l.outcome = l.outcome || e + terminate() + throw e + } @@ -1859,15 +1905,15 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f + function release() { + const c = stop(l, Lease.Ended) + c && c.release() -+ } -+ -+ function terminate() { -+ const c = stop(l, Lease.Ended) -+ c && c.terminate() } - async function scope(c, fn, name) { - const sql = Sql(handler) ++ function terminate() { ++ const c = stop(l, Lease.Ended) ++ c && c.terminate() ++ } ++ + async function scope(fn, name) { + const sql = Sql(track) sql.savepoint = savepoint @@ -1887,13 +1933,14 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f try { result = await new Promise((resolve, reject) => { const x = fn(sql) -@@ -265,17 +422,29 @@ function Postgres(a, b) { +@@ -265,17 +448,30 @@ function Postgres(a, b) { if (uncaughtError) throw uncaughtError } catch (e) { - await (name - ? sql`rollback to ${ sql(name) }` - : sql`rollback` ++ name || (l.outcome = e) + l.state === Lease.Active && await (name + ? sql.unsafe('rollback to ' + escapeIdentifier(name)) + : finish(final => final.unsafe('rollback')) @@ -1923,14 +1970,14 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f } return result -@@ -285,23 +454,21 @@ function Postgres(a, b) { +@@ -285,23 +481,21 @@ function Postgres(a, b) { return savepoint(sql => sql.apply(sql, arguments)) arguments.length === 1 && (fn = name, name = null) - return scope(c, fn, 's' + savepoints++ + (name ? '_' + name : '')) + const id = 's' + savepoints++ + (name ? '_' + name : '') + return watched(transactionChannel) -+ ? traceAsync(transactionChannel, transactionContext(options, undefined, id), () => scope(fn, id)) ++ ? traceAsync(transactionChannel, transactionContext(options, l.connection.session, undefined, id), () => scope(fn, id)) + : scope(fn, id) } @@ -1956,7 +2003,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f } } -@@ -310,7 +477,7 @@ function Postgres(a, b) { +@@ -310,7 +504,7 @@ function Postgres(a, b) { queue.push(c) c.queue = queue queue === open @@ -1965,7 +2012,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f : c.idleTimer.cancel() return c } -@@ -330,15 +497,51 @@ function Postgres(a, b) { +@@ -330,15 +524,51 @@ function Postgres(a, b) { if (ending) return query.reject(Errors.connection('CONNECTION_ENDED', options, options)) @@ -2022,7 +2069,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f } function go(c, query) { -@@ -348,17 +551,18 @@ function Postgres(a, b) { +@@ -348,17 +578,18 @@ function Postgres(a, b) { } function cancel(query) { @@ -2051,7 +2098,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f }) } -@@ -368,13 +572,23 @@ function Postgres(a, b) { +@@ -368,13 +599,23 @@ function Postgres(a, b) { await 1 let timer @@ -2082,7 +2129,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f } async function close() { -@@ -382,9 +596,9 @@ function Postgres(a, b) { +@@ -382,9 +623,9 @@ function Postgres(a, b) { } async function destroy(resolve) { @@ -2093,7 +2140,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f resolve() } -@@ -398,12 +612,18 @@ function Postgres(a, b) { +@@ -398,12 +639,18 @@ function Postgres(a, b) { move(c, ended) } @@ -2115,7 +2162,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f while (ready && queries.length && max-- > 0) { const query = queries.shift() -@@ -418,12 +638,60 @@ function Postgres(a, b) { +@@ -418,12 +665,60 @@ function Postgres(a, b) { : move(c, full) } @@ -2180,7 +2227,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f } } -@@ -453,16 +721,17 @@ function parseOptions(a, b) { +@@ -453,16 +748,17 @@ function parseOptions(a, b) { connect_timeout : 30, max_lifetime : max_lifetime, max_pipeline : 100, @@ -2200,7 +2247,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f host : Array.isArray(host) ? host : host.split(',').map(x => x.split(':')[0]), port : Array.isArray(port) ? port : host.split(',').map(x => parseInt(x.split(':')[1] || port)), path : o.path || host.indexOf('/') > -1 && host + '/.s.PGSQL.' + port, -@@ -472,8 +741,8 @@ function parseOptions(a, b) { +@@ -472,8 +768,8 @@ function parseOptions(a, b) { ...Object.entries(defaults).reduce( (acc, [k, d]) => { const value = k in o ? o[k] : k in query @@ -2211,7 +2258,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f acc[k] = typeof value === 'string' && ints.includes(k) ? +value : value -@@ -488,16 +757,26 @@ function parseOptions(a, b) { +@@ -488,16 +784,26 @@ function parseOptions(a, b) { }, types : o.types || {}, target_session_attrs: tsa(o, url, env), @@ -2239,7 +2286,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..f12e3a1810f7bd9a83123f918df3942f } function tsa(o, url, env) { -@@ -508,8 +787,12 @@ function tsa(o, url, env) { +@@ -508,8 +814,12 @@ function tsa(o, url, env) { throw new Error('target_session_attrs ' + x + ' is not supported') } @@ -2654,19 +2701,21 @@ index 0000000000000000000000000000000000000000..bcdf4c1894e0f84957d7ff3890bea9d8 +} diff --git a/src/tracing.js b/src/tracing.js new file mode 100644 -index 0000000000000000000000000000000000000000..455b379b2f255ae8dbb6ebbc453afcd1fa317d24 +index 0000000000000000000000000000000000000000..3de24120abd1577f11186ce01e93b6325eae5af0 --- /dev/null +++ b/src/tracing.js -@@ -0,0 +1,110 @@ +@@ -0,0 +1,128 @@ +import dc from 'diagnostics_channel' + +const supported = Boolean(dc) && typeof dc.tracingChannel === 'function' + +export const queryChannel = supported ? dc.tracingChannel('postgres:query') : undefined -+export const connectionChannel = supported ? dc.tracingChannel('postgres:connection') : undefined ++export const connectChannel = supported ? dc.tracingChannel('postgres:connect') : undefined +export const transactionChannel = supported ? dc.tracingChannel('postgres:transaction') : undefined ++export const poolConnectChannel = supported ? dc.tracingChannel('postgres:pool:connect') : undefined +export const sendChannel = supported ? dc.channel('postgres:query:send') : undefined -+export const closeChannel = supported ? dc.channel('postgres:connection:close') : undefined ++export const poolReleaseChannel = supported ? dc.channel('postgres:pool:release') : undefined ++export const poolRemoveChannel = supported ? dc.channel('postgres:pool:remove') : undefined + +export function watched(channel) { + return channel !== undefined && (channel.hasSubscribers ?? channel.start.hasSubscribers) @@ -2701,51 +2750,71 @@ index 0000000000000000000000000000000000000000..455b379b2f255ae8dbb6ebbc453afcd1 + ) +} + -+export function serverOf({ host, port, path }) { ++function serverOf({ host, port, path }) { + return path -+ ? { serverAddress: path, serverPort: undefined } -+ : host.length === 1 -+ ? { serverAddress: host[0], serverPort: port[0] } -+ : { serverAddress: undefined, serverPort: undefined } ++ ? { host: path, port: undefined } ++ : host && host.length === 1 ++ ? { host: host[0], port: port[0] } ++ : { host: undefined, port: undefined } ++} ++ ++function clientOf(config) { ++ return { database: config.database, ...serverOf(config), user: config.user, ssl: Boolean(config.ssl), processID: undefined } +} + -+export function connectionContext(connectionId, config) { -+ return { connectionId, database: config.database, user: config.user, ...serverOf(config) } ++export function connectContext(config) { ++ const { database, host, port, user, ssl } = clientOf(config) ++ return { connection: { database, host, port, user, ssl } } +} + -+export function connectionAttempt(context, serverAddress, serverPort) { -+ context.serverAddress = serverAddress -+ context.serverPort = serverPort ++export function connectAttempt(context, host, port) { ++ context.connection.host = host ++ context.connection.port = port +} + -+export function transactionContext(config, options, savepoint) { -+ return { database: config.database, user: config.user, options, savepoint, prepared: undefined } ++export function connectSecured(context, ssl) { ++ context.connection.ssl = ssl ++} ++ ++export function transactionContext(config, session, options, savepoint) { ++ return { client: { ...clientOf(config), ...session }, options, savepoint, prepared: undefined } ++} ++ ++export function transactionSession(context, session) { ++ Object.assign(context.client, session) +} + +export function transactionCommitted(context, committed) { + context.result = { committed } +} + ++export function poolContext({ totalCount, idleCount, waitingCount, max }) { ++ return { pool: { totalCount, idleCount, waitingCount, maxSize: max } } ++} ++ ++export function poolAcquired(context, processID, reused) { ++ context.client = { processID, reused } ++} ++ ++export function poolReleased(connection, error) { ++ if (poolReleaseChannel && poolReleaseChannel.hasSubscribers) ++ poolReleaseChannel.publish({ client: { processID: connection.session.processID }, error }) ++} ++ ++export function poolRemoved(processID, error) { ++ poolRemoveChannel && poolRemoveChannel.hasSubscribers && poolRemoveChannel.publish({ client: { processID }, error }) ++} ++ +export function traceQuery(q, fn) { -+ const config = q.handler.config -+ q.trace = { -+ query: undefined, -+ values: undefined, -+ database: config && config.database, -+ user: config && config.user, -+ ...config && serverOf(config), -+ connectionId: undefined -+ } ++ q.trace = { query: { text: undefined, name: undefined }, client: clientOf(q.handler.config || {}) } + return run(queryChannel, q.trace, fn, q) +} + -+export function querySent(q, connectionId, serverAddress, serverPort) { ++export function querySent(q, session) { + const context = q.trace -+ context.query = q.string -+ context.values = q.parameters -+ context.connectionId = connectionId -+ context.serverAddress = serverAddress -+ context.serverPort = serverPort ++ context.query.text = q.string ++ context.query.name = q.statement.name || undefined ++ Object.assign(context.client, session) + sendChannel.hasSubscribers && sendChannel.publish(context) +} + @@ -2764,10 +2833,6 @@ index 0000000000000000000000000000000000000000..455b379b2f255ae8dbb6ebbc453afcd1 + q.trace = undefined + rejected(queryChannel, context, error) +} -+ -+export function connectionClosed(connectionId, error) { -+ closeChannel && closeChannel.hasSubscribers && closeChannel.publish({ connectionId, error }) -+} diff --git a/src/transport.js b/src/transport.js new file mode 100644 index 0000000000000000000000000000000000000000..990ba27822fbfaca189a3a973c87ba10e58f1ec2 @@ -2998,7 +3063,7 @@ index 0000000000000000000000000000000000000000..e8a4bddf7c0f4b6c55ec7c5f33865888 + } +} diff --git a/types/index.d.ts b/types/index.d.ts -index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..8476e100a816110774c2607a5a5b0c6ccc69cb4f 100644 +index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..2c07940cd38d4af902b74959d7a28d144744b424 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,4 +1,4 @@ @@ -3180,42 +3245,62 @@ index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..8476e100a816110774c2607a5a5b0c6c } interface TransactionSql = {}> extends ISql { -@@ -727,8 +798,63 @@ declare namespace postgres { +@@ -727,8 +798,91 @@ declare namespace postgres { prepare(name: string): Promise>; } -+ /** Context of the `postgres:query` tracing channel. `query`, `values`, `serverAddress`, `serverPort` and `connectionId` are set when the query is written to a connection, after `start`. */ -+ interface QueryTraceContext { -+ /** SQL text with `$n` placeholders as sent to the server */ -+ query: string | undefined; -+ /** Parameters before they are serialized for the wire, raw */ -+ values: unknown[] | undefined; ++ /** The connection a traced call runs on. Host, port and ssl are those of the connection once it is known. */ ++ interface TraceClient { + database: string; ++ host: string | undefined; ++ port: number | undefined; + user: string; -+ serverAddress: string | undefined; -+ serverPort: number | undefined; -+ connectionId: number | undefined; ++ ssl: boolean; ++ /** Backend process id; set once the call is written to a connection */ ++ processID: number | undefined; ++ } ++ ++ /** Context of the `postgres:query` tracing channel. `query` and the connection fields of `client` are set when the query is written to a connection, after `start`. */ ++ interface QueryTraceContext { ++ query: { ++ /** SQL text with `$n` placeholders as sent to the server */ ++ text: string | undefined; ++ /** Prepared statement name; undefined for an unnamed statement */ ++ name: string | undefined; ++ }; ++ client: TraceClient; + /** Set before `asyncStart` when the query succeeded */ + result?: ResultMeta | Statement | Readable | Writable; + /** Set before `error` when the query failed */ + error?: Error; + } + -+ /** Context of the `postgres:connection` tracing channel, one span for each connection acquisition. */ -+ interface ConnectionTraceContext { -+ connectionId: number; -+ database: string; -+ user: string; -+ /** The host of the latest attempt; undefined before the first attempt when several hosts are configured */ -+ serverAddress: string | undefined; -+ serverPort: number | undefined; ++ /** Context of the `postgres:connect` tracing channel, one span for each connection acquisition. */ ++ interface ConnectTraceContext { ++ connection: Omit; ++ error?: Error; ++ } ++ ++ /** Context of the `postgres:pool:connect` tracing channel, for `sql.reserve()` and `sql.begin()`. */ ++ interface PoolConnectTraceContext { ++ pool: { ++ totalCount: number; ++ idleCount: number; ++ waitingCount: number; ++ maxSize: number; ++ }; ++ /** Set before `asyncStart` on success */ ++ client?: { ++ processID: number | undefined; ++ /** False when the acquisition had to open the connection */ ++ reused: boolean; ++ }; + error?: Error; + } + + /** Context of the `postgres:transaction` tracing channel, for `sql.begin()` and `sql.savepoint()`. */ + interface TransactionTraceContext { -+ database: string; -+ user: string; ++ client: TraceClient; + /** The mode string passed to `sql.begin()`; undefined for a savepoint */ + options: string | undefined; + /** The savepoint name as sent to the server; undefined for `sql.begin()` */ @@ -3227,9 +3312,17 @@ index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..8476e100a816110774c2607a5a5b0c6c + error?: Error; + } + -+ /** Payload of the `postgres:connection:close` channel. */ -+ interface ConnectionCloseMessage { -+ connectionId: number; ++ /** Message of the `postgres:pool:release` channel. */ ++ interface PoolReleaseMessage { ++ client: { processID: number | undefined }; ++ /** The error the lease ended with */ ++ error: Error | undefined; ++ } ++ ++ /** Message of the `postgres:pool:remove` channel. */ ++ interface PoolRemoveMessage { ++ client: { processID: number | undefined }; ++ /** The error that closed the connection; undefined for a normal close */ + error: Error | undefined; + } + From 74345d54e4e53c3c1d7fdedd4e41a31e3282d086 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 17:44:00 +0100 Subject: [PATCH 16/34] test: a prepared query carries its statement name in the traced context --- tests/tracing.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tracing.js b/tests/tracing.js index 9cf4f318..5a22056a 100644 --- a/tests/tracing.js +++ b/tests/tracing.js @@ -71,11 +71,11 @@ t('Tracing publishes a query in order with the context filled in as it is built' const { host, port } = server(sql.options) return [ JSON.stringify([ - 'start end publish asyncStart asyncEnd', 'select $1::int as x', null, 'SELECT', 1, + 'start end publish asyncStart asyncEnd', 'select $1::int as x', true, 'SELECT', 1, sql.options.database, sql.options.user, host, port, false, true, true, false, true ]), JSON.stringify([ - events.filter(x => x.name !== 'postgres:pool:remove').map(x => x.stage).join(' '), done.query.text, done.query.name || null, + events.filter(x => x.name !== 'postgres:pool:remove').map(x => x.stage).join(' '), done.query.text, typeof done.query.name === 'string', done.result.command, done.result.count, done.client.database, done.client.user, done.client.host, done.client.port, done.client.ssl, typeof done.client.processID === 'number', send.context.client === done.client, 'values' in done, remove.context.client.processID === done.client.processID From 22d8fdfde818347ab293de465dce00617f00fcfb Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 23:45:39 +0100 Subject: [PATCH 17/34] feat: give every wait for a connection a postgres:pool:connect span (#73) A plain pooled query now publishes postgres:pool:connect as a child of its query span. The span starts when the pool routes the query and ends when the connection makes it the active query, whether the connection was idle or the query had to wait behind others, so postgres:connect for a connection the routing opens nests inside it. When a connection takes the query over it records handoverTime (epoch milliseconds), queuedBehind (the queries already running or waiting on that connection) and the client with reused false for a query that opened its connection. handoverTime minus the start time is the wait for a connection, and the rest is time behind other queries. A lease records the same fields with queuedBehind 0. A query that settles while its pool wait is still open fails the wait first, from the query's own settlement, so ending the pool, a copy in progress, cancellation, a failed connection attempt and a connection lost behind other queries all end the span exactly once, before the query's asyncEnd. postgres:query:send is removed: the pool span now shows the wait, and the query context is still filled in when the query is built. --- src/connection.js | 10 +++++++--- src/index.js | 12 +++++++++++- src/query.js | 2 ++ src/tracing.js | 28 +++++++++++++++++++++++++--- tests-types/index.ts | 2 +- types/index.d.ts | 6 +++++- 6 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/connection.js b/src/connection.js index cdb40559..2bf865a1 100644 --- a/src/connection.js +++ b/src/connection.js @@ -21,7 +21,9 @@ import { connectContext, connectAttempt, connectSecured, - querySent, + queryBuilt, + poolHandover, + poolStarted, poolRemoved } from './tracing.js' @@ -589,12 +591,13 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose return true q.owner === undefined && (q.owner = connection.owner) + q.poolWait && poolHandover(q, (query ? 1 : 0) + sent.length, session()) try { q.state = backend query ? sent.push(q) - : (query = q, query.active = true) + : (query = q, query.active = true, q.poolWait && poolStarted(q)) if (q.cancelled) { q.cancelled = null @@ -687,7 +690,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose : { string, types, name: q.prepare ? statementId + statementCount++ : '' } typeof options.debug === 'function' && callback(options.debug, id, string, parameters, types) - q.trace && querySent(q, session()) + q.trace && queryBuilt(q, session()) } function write(x, fn) { @@ -897,6 +900,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose query = sent.length ? sent.shift() : null if (query) { query.active = true + query.poolWait && poolStarted(query) query.cancelled && cancelRequest(options, query.state).then(query.cancelled.resolve, query.cancelled.reject) } diff --git a/src/index.js b/src/index.js index a554f987..badfc62b 100644 --- a/src/index.js +++ b/src/index.js @@ -32,6 +32,7 @@ import { transactionChannel, poolConnectChannel, watched, + run, traceAsync, transactionContext, transactionSession, @@ -521,6 +522,15 @@ function Postgres(a, b) { } function handler(query) { + if (query.trace && watched(poolConnectChannel)) { + query.poolWait = poolContext(stats()) + return run(poolConnectChannel, query.poolWait, route, query) + } + + route(query) + } + + function route(query) { if (ending) return query.reject(Errors.connection('CONNECTION_ENDED', options, options)) @@ -529,7 +539,7 @@ function Postgres(a, b) { return go(idle, query) if (closed.length) - return connect(closed.shift(), query) + return (query.poolWait && (query.fresh = true), connect(closed.shift(), query)) const c = takeBusy() c diff --git a/src/query.js b/src/query.js index 4d259036..73f6efd9 100644 --- a/src/query.js +++ b/src/query.js @@ -34,6 +34,8 @@ export class Query extends Promise { this.cancelled = null this.executed = false this.trace = undefined + this.poolWait = undefined + this.fresh = false this.signature = '' this[originError] = this.handler.debug diff --git a/src/tracing.js b/src/tracing.js index 3de24120..cc972557 100644 --- a/src/tracing.js +++ b/src/tracing.js @@ -1,4 +1,5 @@ import dc from 'diagnostics_channel' +import { performance } from 'perf_hooks' const supported = Boolean(dc) && typeof dc.tracingChannel === 'function' @@ -6,7 +7,6 @@ export const queryChannel = supported ? dc.tracingChannel('postgres:query') : un export const connectChannel = supported ? dc.tracingChannel('postgres:connect') : undefined export const transactionChannel = supported ? dc.tracingChannel('postgres:transaction') : undefined export const poolConnectChannel = supported ? dc.tracingChannel('postgres:pool:connect') : undefined -export const sendChannel = supported ? dc.channel('postgres:query:send') : undefined export const poolReleaseChannel = supported ? dc.channel('postgres:pool:release') : undefined export const poolRemoveChannel = supported ? dc.channel('postgres:pool:remove') : undefined @@ -86,9 +86,30 @@ export function poolContext({ totalCount, idleCount, waitingCount, max }) { } export function poolAcquired(context, processID, reused) { + context.handoverTime = performance.timeOrigin + performance.now() + context.queuedBehind = 0 context.client = { processID, reused } } +export function poolHandover(q, queuedBehind, session) { + const context = q.poolWait + context.handoverTime = performance.timeOrigin + performance.now() + context.queuedBehind = queuedBehind + context.client = { processID: session.processID, reused: !q.fresh } +} + +export function poolStarted(q) { + const context = q.poolWait + q.poolWait = undefined + resolved(poolConnectChannel, context) +} + +function poolFailed(q, error) { + const context = q.poolWait + q.poolWait = undefined + rejected(poolConnectChannel, context, error) +} + export function poolReleased(connection, error) { if (poolReleaseChannel && poolReleaseChannel.hasSubscribers) poolReleaseChannel.publish({ client: { processID: connection.session.processID }, error }) @@ -103,18 +124,18 @@ export function traceQuery(q, fn) { return run(queryChannel, q.trace, fn, q) } -export function querySent(q, session) { +export function queryBuilt(q, session) { const context = q.trace context.query.text = q.string context.query.name = q.statement.name || undefined Object.assign(context.client, session) - sendChannel.hasSubscribers && sendChannel.publish(context) } export function querySettled(q, result) { if (q.streaming && result && typeof result.pipe === 'function') return + q.poolWait && poolStarted(q) const context = q.trace q.trace = undefined context.result = result @@ -122,6 +143,7 @@ export function querySettled(q, result) { } export function queryFailed(q, error) { + q.poolWait && poolFailed(q, error) const context = q.trace q.trace = undefined rejected(queryChannel, context, error) diff --git a/tests-types/index.ts b/tests-types/index.ts index b1549e8f..b6dd60f2 100644 --- a/tests-types/index.ts +++ b/tests-types/index.ts @@ -31,7 +31,7 @@ new postgres.PostgresError(42) const client: postgres.TraceClient = { database: 'a', host: 'localhost', port: 5432, user: 'b', ssl: false, processID: undefined } const queryTrace: postgres.QueryTraceContext = { query: { text: undefined, name: undefined }, client } const connectTrace: postgres.ConnectTraceContext = { connection: { database: 'a', host: 'localhost', port: 5432, user: 'b', ssl: false } } -const poolConnectTrace: postgres.PoolConnectTraceContext = { pool: { totalCount: 1, idleCount: 0, waitingCount: 0, maxSize: 10 }, client: { processID: 1, reused: true } } +const poolConnectTrace: postgres.PoolConnectTraceContext = { pool: { totalCount: 1, idleCount: 0, waitingCount: 0, maxSize: 10 }, handoverTime: 1, queuedBehind: 0, client: { processID: 1, reused: true } } const transactionTrace: postgres.TransactionTraceContext = { client, options: 'read write', savepoint: undefined, prepared: undefined, result: { committed: true } } const releaseMessage: postgres.PoolReleaseMessage = { client: { processID: 1 }, error: undefined } const removeMessage: postgres.PoolRemoveMessage = { client: { processID: 1 }, error: undefined } diff --git a/types/index.d.ts b/types/index.d.ts index 2c07940c..3c4b9022 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -830,7 +830,7 @@ declare namespace postgres { error?: Error; } - /** Context of the `postgres:pool:connect` tracing channel, for `sql.reserve()` and `sql.begin()`. */ + /** Context of the `postgres:pool:connect` tracing channel: every wait for a connection, for `sql.reserve()`, `sql.begin()` and plain pooled queries. */ interface PoolConnectTraceContext { pool: { totalCount: number; @@ -838,6 +838,10 @@ declare namespace postgres { waitingCount: number; maxSize: number; }; + /** Epoch milliseconds at which the connection took the call over; set before `asyncStart` on success. `handoverTime` minus the `start` time is the wait for a connection. */ + handoverTime?: number; + /** Queries the connection already had running or waiting when it took this one over; 0 for a lease. The time from `handoverTime` to `asyncStart` is spent behind them. */ + queuedBehind?: number; /** Set before `asyncStart` on success */ client?: { processID: number | undefined; From 517b099f773f4a08751d149cb877312483897dda Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 23:48:11 +0100 Subject: [PATCH 18/34] fix: report a refused query after the pool span's end event A query refused while it was being routed (the pool is ending, or a COPY holds every connection) was rejected inside the pool span's run, so the span published its error before its end. The routing now returns the refusal and the handler rejects after run, which gives the order of tracePromise. --- src/index.js | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/index.js b/src/index.js index badfc62b..3f1048a4 100644 --- a/src/index.js +++ b/src/index.js @@ -522,31 +522,32 @@ function Postgres(a, b) { } function handler(query) { - if (query.trace && watched(poolConnectChannel)) { - query.poolWait = poolContext(stats()) - return run(poolConnectChannel, query.poolWait, route, query) - } - - route(query) + const refusal = query.trace && watched(poolConnectChannel) + ? run(poolConnectChannel, query.poolWait = poolContext(stats()), route, query) + : route(query) + refusal && query.reject(refusal) } function route(query) { if (ending) - return query.reject(Errors.connection('CONNECTION_ENDED', options, options)) + return Errors.connection('CONNECTION_ENDED', options, options) const idle = takeOpen() if (idle) - return go(idle, query) + return (go(idle, query), null) if (closed.length) - return (query.poolWait && (query.fresh = true), connect(closed.shift(), query)) + return (query.poolWait && (query.fresh = true), connect(closed.shift(), query), null) const c = takeBusy() - c - ? go(c, query) - : allHeldByCopy() - ? query.reject(copyInProgress()) - : queries.push(query) + if (c) + return (go(c, query), null) + + if (allHeldByCopy()) + return copyInProgress() + + queries.push(query) + return null } function allHeldByCopy() { From c8e1f59a2eef431db808c970e831821aead8f312 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 23:48:11 +0100 Subject: [PATCH 19/34] test: cover the pool span of plain queries and drop the send channel checks Checks the pool span for a query on an idle connection, on a connection it opens (connect nested in the pool span through a bound store), sent ahead to a busy connection (it ends after the earlier query's result), and drained from the pool queue. Failures before the query starts are covered for an ending pool, a COPY in progress, cancellation in the pool queue, a refused connection, and a connection lost while the query waited behind others. Each span settles once and before its query, and nothing is published when only one of the query and pool channels has a subscriber. --- tests/tracing.js | 233 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 218 insertions(+), 15 deletions(-) diff --git a/tests/tracing.js b/tests/tracing.js index 5a22056a..6646ed86 100644 --- a/tests/tracing.js +++ b/tests/tracing.js @@ -4,6 +4,8 @@ import { AsyncLocalStorage } from 'async_hooks' import { t } from './test.js' import postgres from '../src/index.js' +const now = () => performance.timeOrigin + performance.now() +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) const rel = x => new URL(x, import.meta.url) const stages = ['start', 'end', 'asyncStart', 'asyncEnd', 'error'] @@ -21,7 +23,7 @@ function collect(...names) { const ids = new Map() const id = context => ids.get(context) || (ids.set(context, ids.size + 1), ids.size) for (const name of names.filter(x => x.startsWith('postgres:'))) { - const handler = context => events.push({ name, stage: 'publish', id: id(context), context: { ...context } }) + const handler = context => events.push({ name, stage: 'publish', id: id(context), time: now(), context: { ...context } }) dc.subscribe(name, handler) undo.push(() => dc.unsubscribe(name, handler)) } @@ -29,7 +31,7 @@ function collect(...names) { for (const name of names.filter(x => !x.startsWith('postgres:'))) { const channel = dc.tracingChannel('postgres:' + name) const handlers = Object.fromEntries(stages.map(stage => - [stage, context => events.push({ name, stage, id: id(context), context: { ...context } })] + [stage, context => events.push({ name, stage, id: id(context), time: now(), context: { ...context } })] )) channel.subscribe(handlers) undo.push(() => channel.unsubscribe(handlers)) @@ -61,23 +63,22 @@ async function traced(names, fn) { t('Tracing publishes a query in order with the context filled in as it is built', async() => { const sql = postgres(options) - const { events, stop } = collect('query', 'postgres:query:send', 'postgres:pool:remove') + const { events, stop } = collect('query', 'postgres:pool:remove') await sql`select ${ 5 }::int as x` await sql.end() stop() const [done] = ended(events) - const [send] = of(events, 'postgres:query:send') const [remove] = of(events, 'postgres:pool:remove') const { host, port } = server(sql.options) return [ JSON.stringify([ - 'start end publish asyncStart asyncEnd', 'select $1::int as x', true, 'SELECT', 1, - sql.options.database, sql.options.user, host, port, false, true, true, false, true + plain, 'select $1::int as x', true, 'SELECT', 1, + sql.options.database, sql.options.user, host, port, false, true, false, true ]), JSON.stringify([ - events.filter(x => x.name !== 'postgres:pool:remove').map(x => x.stage).join(' '), done.query.text, typeof done.query.name === 'string', + sequence(events, 'query'), done.query.text, typeof done.query.name === 'string', done.result.command, done.result.count, done.client.database, done.client.user, done.client.host, done.client.port, done.client.ssl, - typeof done.client.processID === 'number', send.context.client === done.client, 'values' in done, + typeof done.client.processID === 'number', 'values' in done, remove.context.client.processID === done.client.processID ]) ] @@ -158,9 +159,9 @@ t('Tracing pipelined queries settle once each with their own context', async() = ] }) -t('Tracing a query cancelled before it is sent settles with the cancellation and is never sent', async() => { +t('Tracing a query cancelled before it is sent settles with the cancellation and is never built', async() => { const sql = postgres(options) - const { events, stop } = collect('query', 'postgres:query:send') + const { events, stop } = collect('query') const slow = sql`select pg_sleep(0.2)`.execute() const waiting = sql`select 2 as x` const code = waiting.then(() => 'resolved', e => e.code) @@ -295,7 +296,7 @@ t('Nothing is published when nothing subscribes', async() => { const published = [] const restore = [] const names = ['query', 'connect', 'transaction', 'pool:connect'].flatMap(name => stages.map(stage => 'tracing:postgres:' + name + ':' + stage)) - .concat('postgres:query:send', 'postgres:pool:release', 'postgres:pool:remove') + .concat('postgres:pool:release', 'postgres:pool:remove') for (const name of names) { const channel = dc.channel(name) for (const method of ['publish', 'runStores']) { @@ -362,9 +363,9 @@ t('Tracing a query cancelled before it is awaited opens no span, on a pool that [JSON.stringify(['57014', 1, 1]), await cancelledBeforeAwait({ max: 2 }, false)] ) -t('Tracing a retried query settles once and is sent again', async() => { +t('Tracing a retried query settles once', async() => { const sql = postgres(options) - const { events, stop } = collect('query', 'postgres:query:send') + const { events, stop } = collect('query') await sql`drop table if exists tracing_retry` await sql`create table tracing_retry (name text)` await sql`insert into tracing_retry values ('1')` @@ -374,8 +375,7 @@ t('Tracing a retried query settles once and is sent again', async() => { await sql`drop table tracing_retry` stop() await sql.end() - const sends = of(events, 'postgres:query:send').filter(x => x.context.query.text === 'select name from tracing_retry').length - return [JSON.stringify([1, 7, 7, 3]), JSON.stringify([row.name, count(events, 'start'), count(events, 'asyncEnd'), sends])] + return [JSON.stringify([1, 7, 7]), JSON.stringify([row.name, count(events, 'start'), count(events, 'asyncEnd')])] }) t('Tracing a server error in the middle of a cursor settles once with the error', async() => { @@ -515,3 +515,206 @@ t('Tracing a transaction publishes its release with the error that ended it', as ]) ] }) + +const spans = (events, name = 'pool:connect') => ended(events, name) +const at = (events, name, stage, nth = 0) => events.indexOf(of(events, name).filter(x => x.stage === stage)[nth]) +const asyncEndOf = (events, text) => events.indexOf(of(events, 'query').find(x => x.stage === 'asyncEnd' && x.context.query.text === text)) + +t('Tracing a query on an idle connection has a pool span that starts and ends at once', async() => { + const sql = postgres(options) + await sql`select 1` + const { events, stop } = collect('query', 'pool:connect') + await sql`select 2 as x` + stop() + await sql.end() + const [span] = spans(events) + const start = of(events, 'pool:connect').find(x => x.stage === 'start') + return [ + JSON.stringify([plain, 0, true, true, true, true]), + JSON.stringify([ + sequence(events, 'pool:connect'), span.queuedBehind, span.handoverTime >= start.time, span.client.reused, + typeof span.client.processID === 'number', at(events, 'query', 'start') < at(events, 'pool:connect', 'start') + ]) + ] +}) + +t('Tracing a query that opens its connection nests the connect span inside the pool span', async() => { + const storage = new AsyncLocalStorage() + const sql = postgres(options) + const pool = dc.tracingChannel('postgres:pool:connect') + const connect = dc.tracingChannel('postgres:connect') + const seen = [] + const probe = { start: () => seen.push(storage.getStore()) } + pool.start.bindStore(storage, () => 'pool') + connect.subscribe(probe) + const { events, stop } = collect('query', 'pool:connect', 'connect') + try { + await sql`select 1` + } finally { + stop() + connect.unsubscribe(probe) + pool.start.unbindStore(storage) + await sql.end() + } + const [span] = spans(events) + return [ + JSON.stringify([['pool'], false, plain, true, true]), + JSON.stringify([ + seen, span.client.reused, sequence(events, 'pool:connect'), + at(events, 'pool:connect', 'start') < at(events, 'connect', 'start'), + at(events, 'connect', 'asyncEnd') < at(events, 'pool:connect', 'asyncEnd') + ]) + ] +}) + +t('Tracing a query sent ahead to a busy connection ends its pool span when the earlier query is done', async() => { + const sql = postgres(options) + await sql`select 1` + const { events, stop } = collect('query', 'pool:connect') + const slow = sql`select pg_sleep(0.2)`.execute() + await delay(50) + const waiting = sql`select 2 as x` + await Promise.all([slow, waiting]) + stop() + await sql.end() + const [first, second] = spans(events) + return [ + JSON.stringify([0, true, true, true]), + JSON.stringify([ + first.queuedBehind, second.queuedBehind >= 1, second.client.reused, + asyncEndOf(events, 'select pg_sleep(0.2)') < at(events, 'pool:connect', 'asyncEnd', 1) + ]) + ] +}) + +t('Tracing queries drained from the pool queue when the connection opens wait behind the first', async() => { + const sql = postgres(options) + const { events, stop } = collect('query', 'pool:connect') + await Promise.all([sql`select 1 as x`, sql`select 2 as x`, sql`select 3 as x`]) + stop() + await sql.end() + const found = spans(events) + const pairs = [0, 1, 2].map(i => at(events, 'pool:connect', 'asyncEnd', i) < at(events, 'query', 'asyncEnd', i)) + return [ + JSON.stringify([3, 3, [false, true, true], true, [true, true, true]]), + JSON.stringify([ + count(events, 'asyncEnd', 'pool:connect'), count(events, 'start', 'pool:connect'), found.map(x => x.client.reused), + found[1].queuedBehind >= 1, pairs + ]) + ] +}) + +t('Tracing fails the pool span before the query when the pool is ending', async() => { + const sql = postgres(options) + await sql`select 1` + const { events, stop } = collect('query', 'pool:connect') + const ending = sql.end() + const code = await sql`select 2 as x`.catch(e => e.code) + await ending + stop() + const [span] = spans(events) + return [ + JSON.stringify(['CONNECTION_ENDED', failed, 'CONNECTION_ENDED', true]), + JSON.stringify([ + code, sequence(events, 'pool:connect'), span.error.code, + at(events, 'pool:connect', 'asyncEnd') < at(events, 'query', 'asyncEnd') + ]) + ] +}) + +t('Tracing fails the pool span of a query refused during a COPY', async() => { + const sql = postgres(options) + await sql`create temp table tracing_pool_copy (x int)` + const writable = await sql`copy tracing_pool_copy from stdin`.writable() + const { events, stop } = collect('query', 'pool:connect') + const code = await sql`select 1`.catch(e => e.code) + stop() + writable.end('1\n') + await new Promise(resolve => writable.on('finish', resolve)) + await sql.end() + const [span] = spans(events) + return [ + JSON.stringify(['COPY_IN_PROGRESS', failed, 'COPY_IN_PROGRESS']), + JSON.stringify([code, sequence(events, 'pool:connect'), span.error.code]) + ] +}) + +t('Tracing fails the pool span of a query cancelled in the pool queue', async() => { + const sql = postgres(options) + const { events, stop } = collect('query', 'pool:connect') + const first = sql`select pg_sleep(0.1)`.execute() + const waiting = sql`select 2 as x` + const code = waiting.then(() => 'resolved', e => e.code) + await delay(10) + await waiting.cancel() + await first + const result = await code + stop() + await sql.end() + const found = spans(events) + return [ + JSON.stringify(['57014', ['57014', 'ok'], 2, 2]), + JSON.stringify([ + result, found.map(x => x.error ? x.error.code : 'ok').sort(), count(events, 'asyncEnd', 'pool:connect'), count(events, 'start', 'pool:connect') + ]) + ] +}) + +t('Tracing fails the pool span of a query whose connection attempt fails', async() => { + const { events, stop } = collect('query', 'pool:connect', 'connect') + const refused = postgres({ ...options, host: '127.0.0.1', port: 33333 }) + const code = await refused`select 1`.catch(e => e.code) + stop() + return [ + JSON.stringify(['ECONNREFUSED', failed, failed, true]), + JSON.stringify([ + code, sequence(events, 'pool:connect'), sequence(events, 'connect'), + at(events, 'pool:connect', 'asyncEnd') < at(events, 'query', 'asyncEnd') + ]) + ] +}) + +t('Tracing fails the pool span of a query that waited behind others on a connection that is lost', async() => { + const sql = postgres(options) + const killer = postgres({ ...options, max: 1 }) + const [{ pid }] = await sql`select pg_backend_pid() as pid` + const { events, stop } = collect('query', 'pool:connect') + const running = sql`select pg_sleep(5)`.execute() + await delay(50) + const waiting = sql`select 2 as x`.execute() + await killer`select pg_terminate_backend(${ pid })` + const outcomes = await Promise.allSettled([running, waiting]) + stop() + await Promise.all([sql.end(), killer.end()]) + const failures = of(events, 'pool:connect').filter(x => x.stage === 'error') + return [ + JSON.stringify(['rejected', 'rejected', 1, true]), + JSON.stringify([ + outcomes[0].status, outcomes[1].status, failures.length, + count(events, 'start', 'pool:connect') === count(events, 'asyncEnd', 'pool:connect') + ]) + ] +}) + +t('Tracing opens no pool span when only one of the query and pool channels has a subscriber', async() => { + const sql = postgres(options) + const poolOnly = collect('pool:connect') + await sql`select 1` + poolOnly.stop() + const published = [] + const channel = dc.channel('tracing:postgres:pool:connect:start') + const original = channel.runStores + channel.runStores = function() { + published.push('runStores') + return original.apply(this, arguments) + } + const queryOnly = collect('query') + try { + await sql`select 2` + } finally { + queryOnly.stop() + delete channel.runStores + await sql.end() + } + return [JSON.stringify([0, []]), JSON.stringify([poolOnly.events.length, published])] +}) From 8b85367492d20f470d8c3283efb6fc1030ae40d9 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 23:48:38 +0100 Subject: [PATCH 20/34] docs: describe the pool span of plain queries and remove the send channel pool:connect now covers every wait for a connection. The page explains how handoverTime and queuedBehind split the wait, notes the coarse clock on Cloudflare Workers, and shows an OpenTelemetry span event at the handover. --- docs/api.md | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/api.md b/docs/api.md index b4b6732f..d3c6f2d3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1522,7 +1522,7 @@ These publish `start`, `end`, `asyncStart`, `asyncEnd` and `error`. |---|---| | `postgres:query` | Each query: from the call that runs it until the server answers or the query fails | | `postgres:connect` | Each connection acquisition: socket, TLS, authentication, startup and type fetching, up to the connection serving queries. Retries and other hosts stay inside one span. | -| `postgres:pool:connect` | Each `sql.reserve()` and `sql.begin()`: acquiring a connection for exclusive use. Plain pooled queries do not publish it; `postgres:query:send` shows their wait. | +| `postgres:pool:connect` | Each wait for a connection: `sql.reserve()`, `sql.begin()` and every plain pooled query that has a `postgres:query` subscriber. For a plain query it is a child of the query span and ends when the connection starts running the query. Statements sent on a reserved connection or in a transaction do not publish it. | | `postgres:transaction` | `sql.begin()` and, nested in it, each `sql.savepoint()` | ### Plain channels @@ -1531,7 +1531,6 @@ These publish one message. Subscribe with `dc.channel(name).subscribe(callback)` | Channel | Fires for | |---|---| -| `postgres:query:send` | A query is written to a connection. It carries the query's context, and is published only for queries that had a `postgres:query` subscriber when they started. | | `postgres:pool:release` | A reserved connection or a transaction gives its connection back | | `postgres:pool:remove` | A connection closes, including a failed connection attempt | @@ -1542,12 +1541,15 @@ postgres:pool:connect sql.reserve() or sql.begin() acquires a connection └─ postgres:connect only if the acquisition had to open the connection postgres:transaction sql.begin() runs its function └─ postgres:query each statement, BEGIN and COMMIT included - └─ postgres:query:send the statement is written to the connection postgres:pool:release the connection goes back to the pool postgres:pool:remove later, when the connection closes + +postgres:query a plain pooled query + └─ postgres:pool:connect the wait for a connection + └─ postgres:connect only if the query had to open the connection ``` -A plain query publishes `postgres:query` and `postgres:query:send`, and `postgres:connect` when its connection has to be opened. `postgres:query:send` is published again when a query is retried. +A plain query publishes `postgres:query` and, inside it, `postgres:pool:connect`. The pool span ends when the connection makes the query its active query, so a query that waits behind others on a busy connection, or in the pool's queue, is waiting inside it. A query that settles before then, because the pool is ending, a `COPY` holds every connection, it is cancelled, the connection attempt fails or the connection is lost, fails the pool span first and then itself. The `TracingChannel`s publish in the order Node's `tracePromise` does: `start` and `end` in the caller's synchronous code, then for a failure `error`, and for both outcomes `asyncStart` and `asyncEnd`, with `context.result` or `context.error` set first. `start` is published with `runStores`, so a store bound to it, such as an `AsyncLocalStorage` span context, is current in the code that starts the call. Every query publishes `asyncStart` and `asyncEnd` exactly once. That includes cursors read with `for await` or a callback, `forEach`, `COPY` streams (once the copy is over), `describe()`, `simple()`, `unsafe()`, `file()`, pipelined queries, retried queries and queries cancelled before they were sent. The driver's own startup queries (type fetching, `target_session_attrs`) are not published; they belong to the connect span. @@ -1570,7 +1572,7 @@ The types are `QueryTraceContext`, `ConnectTraceContext`, `PoolConnectTraceConte | `result` | before `asyncStart`: the `Result` with `command` and `count`; for `describe()` the statement; for a `COPY` stream the `Result` of the finished copy, not the stream | | `error` | before `error` | -The text is known only after the driver has serialized the query for a connection, so read it at `asyncEnd` or `error`, or rename the span then. The time between `start` and `postgres:query:send` is the wait for a connection, and between that and `asyncEnd` the time on the server. +The text is known only after the driver has serialized the query for a connection, so read it at `asyncEnd` or `error`, or rename the span then. The `postgres:pool:connect` span inside a plain query is the wait for a connection, and the rest of the query span is the time on the server. ### postgres:connect @@ -1587,9 +1589,32 @@ The text is known only after the driver has serialized the query for a connectio |---|---| | `pool.totalCount`, `pool.idleCount`, `pool.waitingCount` | at `start`, as `sql.stats()` reports them | | `pool.maxSize` | at `start`: the `max` option | +| `handoverTime` | before `asyncStart` on success: the epoch milliseconds at which the connection took the call over | +| `queuedBehind` | before `asyncStart` on success: how many queries the connection already had running or waiting when it took a plain query over; 0 for `sql.reserve()` and `sql.begin()` | | `client.processID` | before `asyncStart` on success | | `client.reused` | before `asyncStart` on success: false when the acquisition had to open the connection | -| `error` | before `error`: a connection error, or `CONNECTION_ENDED` after `sql.end()` | +| `error` | before `error`: a connection error, `CONNECTION_ENDED` after `sql.end()`, `COPY_IN_PROGRESS`, or the cancellation or connection error of a plain query that settled before it started | + +`handoverTime` minus the time of `start` is the wait for a connection, which is what OpenTelemetry's `db.client.connection.wait_time` measures. When `queuedBehind` is above 0, the time from `handoverTime` to `asyncStart` was spent behind those queries on the same connection. Take the `start` time yourself in the `start` subscriber. On Cloudflare Workers `performance.now()` advances only after I/O, so within a request both times can be equal. + +```js +import dc from 'node:diagnostics_channel' +import { trace } from '@opentelemetry/api' + +const pool = dc.tracingChannel('postgres:pool:connect') + +pool.subscribe({ + start(context) { + context.span = trace.getActiveSpan() + }, + end() {}, + asyncStart() {}, + asyncEnd(context) { + context.span?.addEvent('connection handed over', { 'postgres.queued_behind': context.queuedBehind }, context.handoverTime) + }, + error() {} +}) +``` ### postgres:transaction From 4c311dffe6427fcfffda6f7911c781ba2d0e06d3 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 23:48:38 +0100 Subject: [PATCH 21/34] docs: refresh the diff since 3.4.9 --- docs/changes-since-3.4.9.diff | 261 ++++++++++++++++++++-------------- 1 file changed, 155 insertions(+), 106 deletions(-) diff --git a/docs/changes-since-3.4.9.diff b/docs/changes-since-3.4.9.diff index dcd23db4..7d068a4c 100644 --- a/docs/changes-since-3.4.9.diff +++ b/docs/changes-since-3.4.9.diff @@ -1,8 +1,8 @@ diff --git a/src/connection.js b/src/connection.js -index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa7c1bb839 100644 +index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c1768d4c69db 100644 --- a/src/connection.js +++ b/src/connection.js -@@ -4,24 +4,53 @@ import crypto from 'crypto' +@@ -4,24 +4,55 @@ import crypto from 'crypto' import Stream from 'stream' import { performance } from 'perf_hooks' @@ -27,7 +27,9 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa + connectContext, + connectAttempt, + connectSecured, -+ querySent, ++ queryBuilt, ++ poolHandover, ++ poolStarted, + poolRemoved +} from './tracing.js' + @@ -61,7 +63,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa const retryRoutines = new Set([ 'FetchPreparedStatement', 'RevalidateCachedQuery', -@@ -49,7 +78,7 @@ const errorFields = { +@@ -49,7 +80,7 @@ const errorFields = { 82 : 'routine' // R } @@ -70,7 +72,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa const { sslnegotiation, ssl, -@@ -72,12 +101,20 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -72,12 +103,20 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose const sent = Queue() , id = uid++ , backend = { pid: null, secret: null } @@ -95,7 +97,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa , errorResponse = null , result = new Result() , incoming = Buffer.alloc(0) -@@ -86,82 +123,472 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -86,102 +125,509 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose , statements = {} , statementId = Math.random().toString(36).slice(2) , statementCount = 1 @@ -603,19 +605,21 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa + return true + + q.owner === undefined && (q.owner = connection.owner) ++ q.poolWait && poolHandover(q, (query ? 1 : 0) + sent.length, session()) try { q.state = backend -@@ -169,19 +596,35 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose + query ? sent.push(q) - : (query = q, query.active = true) - +- : (query = q, query.active = true) ++ : (query = q, query.active = true, q.poolWait && poolStarted(q)) ++ + if (q.cancelled) { + q.cancelled = null + abort(q, Errors.generic('57014', 'canceling statement due to user request')) + return accepting() + } -+ + build(q) - return write(toBuffer(q)) - && !q.describeFirst @@ -650,7 +654,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa function toBuffer(q) { if (q.parameters.length >= 65534) throw Errors.generic('MAX_PARAMETERS_EXCEEDED', 'Max number of parameters (65534) exceeded') -@@ -206,7 +649,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -206,7 +652,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose function prepared(q) { return Buffer.concat([ @@ -659,7 +663,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa q.cursorFn ? Execute('', q.cursorRows) : ExecuteUnnamed -@@ -234,13 +677,17 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -234,13 +680,17 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose q.signature = q.prepare && types + string q.onlyDescribe && (delete statements[q.signature]) q.parameters = q.parameters || parameters @@ -675,11 +679,11 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa - typeof options.debug === 'function' && options.debug(id, string, parameters, types) + typeof options.debug === 'function' && callback(options.debug, id, string, parameters, types) -+ q.trace && querySent(q, session()) ++ q.trace && queryBuilt(q, session()) } function write(x, fn) { -@@ -252,52 +699,12 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -252,52 +702,12 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function nextWrite(fn) { @@ -733,7 +737,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa function data(x) { if (incomings) { incomings.push(x) -@@ -312,6 +719,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -312,6 +722,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose ? x : Buffer.concat([incoming, x], incoming.length + x.length) @@ -741,7 +745,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa while (incoming.length > 4) { length = incoming.readUInt32BE(1) if (length >= incoming.length) { -@@ -323,74 +731,34 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -323,74 +734,34 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose try { handle(incoming.subarray(0, length + 1)) } catch (e) { @@ -830,7 +834,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa } function queryError(query, err) { -@@ -401,7 +769,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -401,7 +772,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose err = new Error(err) 'query' in err || 'parameters' in err || Object.defineProperties(err, { @@ -839,7 +843,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa query: { value: query.string, enumerable: options.debug }, parameters: { value: query.parameters, enumerable: options.debug }, args: { value: query.args, enumerable: options.debug }, -@@ -410,51 +778,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -410,51 +781,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose query.reject(err) } @@ -899,7 +903,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa } /* Handlers */ -@@ -495,6 +826,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -495,6 +829,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose let column let value @@ -908,7 +912,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa const row = query.isRaw ? new Array(query.statement.columns.length) : {} for (let i = 0; i < query.statement.columns.length; i++) { column = query.statement.columns[i] -@@ -514,13 +847,13 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -514,13 +850,13 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose query.isRaw ? (row[i] = query.isRaw === true ? value @@ -926,7 +930,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa } function ParameterStatus(x) { -@@ -528,19 +861,25 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -528,19 +864,25 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose backendParameters[k] = v if (options.parameters[k] !== v) { options.parameters[k] = v @@ -954,7 +958,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa query.resolve(results || result) } } else if (errorResponse) { -@@ -549,42 +888,34 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -549,42 +891,35 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose query = results = errorResponse = null result = new Result() @@ -984,6 +988,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa + query = sent.length ? sent.shift() : null + if (query) { + query.active = true ++ query.poolWait && poolStarted(query) + query.cancelled && cancelRequest(options, query.state).then(query.cancelled.resolve, query.cancelled.reject) } @@ -1019,7 +1024,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa } function CommandComplete(x) { -@@ -602,7 +933,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -602,7 +937,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose final && (final(), final = null) @@ -1028,7 +1033,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa return errored(Errors.generic('UNSAFE_TRANSACTION', 'Only use sql.begin, sql.reserved or max: 1')) if (query.options.simple) -@@ -654,7 +985,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -654,7 +989,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose const number = x.readUInt16BE(index + 4) const type = x.readUInt32BE(index + 6) query.statement.columns[i] = { @@ -1037,7 +1042,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa ? transform.column.from(x.toString('utf8', start, index - 1)) : x.toString('utf8', start, index - 1), parser: parsers[type], -@@ -670,8 +1001,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -670,8 +1005,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose return (query.resolve(query.statement), write(Sync)) } @@ -1049,7 +1054,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa type === 3 ? AuthenticationCleartextPassword : type === 5 ? AuthenticationMD5Password : type === 10 ? SASL : -@@ -679,18 +1011,20 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -679,18 +1015,20 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose type === 12 ? SASLFinal : type !== 0 ? UnknownAuth : noop @@ -1073,7 +1078,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa const payload = 'md5' + ( await md5( Buffer.concat([ -@@ -699,40 +1033,42 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -699,40 +1037,42 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose ]) ) ) @@ -1130,7 +1135,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa write( b().p().str(payload).end() ) -@@ -742,8 +1078,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -742,8 +1082,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose if (x.toString('utf8', 9).split(b.N, 1)[0].slice(2) === serverSignature) return /* c8 ignore next 5 */ @@ -1140,7 +1145,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa } function Pass() { -@@ -765,48 +1100,47 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -765,48 +1104,47 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose backend.secret = x.readUInt32BE(9) } @@ -1212,7 +1217,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa } function ErrorResponse(x) { -@@ -814,12 +1148,11 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -814,12 +1152,11 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose (query.cursorFn || query.describeFirst) && write(Sync) errorResponse = Errors.postgres(parseError(x)) } else { @@ -1226,7 +1231,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa q.retried = error execute(q) } -@@ -830,22 +1163,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -830,22 +1167,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose let index = 9 while (x[index++] !== 0); @@ -1260,7 +1265,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa } } -@@ -862,7 +1200,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -862,7 +1204,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose }, destroy(error, callback) { callback(error) @@ -1269,7 +1274,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa stream = null }, final(callback) { -@@ -872,27 +1210,29 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -872,27 +1214,29 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } }) query.resolve(stream) @@ -1302,7 +1307,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa stream = null }, final(callback) { -@@ -901,22 +1241,36 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -901,22 +1245,36 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } }) query.resolve(stream) @@ -1342,7 +1347,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa } /* c8 ignore next 3 */ -@@ -945,20 +1299,21 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -945,20 +1303,21 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } /* Messages */ @@ -1371,7 +1376,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa prev = b.i b.inc(4).str(x).i32(b.i - prev - 4, prev) -@@ -966,7 +1321,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -966,7 +1325,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose b.i16(0) @@ -1400,7 +1405,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa } function Parse(str, parameters, types, name = '') { -@@ -994,7 +1369,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -994,7 +1373,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function StartupMessage() { @@ -1409,7 +1414,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa Object.entries(Object.assign({ user, database, -@@ -1019,6 +1394,33 @@ function parseError(x) { +@@ -1019,6 +1398,33 @@ function parseError(x) { return error } @@ -1443,7 +1448,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..cdb40559e18d421939e1a863969edcaa function md5(x) { return crypto.createHash('md5').update(x).digest('hex') } -@@ -1042,21 +1444,44 @@ function xor(a, b) { +@@ -1042,21 +1448,44 @@ function xor(a, b) { function timer(fn, seconds) { seconds = typeof seconds === 'function' ? seconds() : seconds if (!seconds) @@ -1529,7 +1534,7 @@ index 0ff83c428bec774e11417b8e1f033dc3305c18cf..023c3ba1bd113e6f5508ab079efcf220 code: x, errno: x, diff --git a/src/index.js b/src/index.js -index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d14b46f3c 100644 +index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf596fb01c 100644 --- a/src/index.js +++ b/src/index.js @@ -3,10 +3,12 @@ import fs from 'fs' @@ -1545,7 +1550,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d toPascal, pascal, toCamel, -@@ -18,12 +20,28 @@ import { +@@ -18,12 +20,29 @@ import { fromKebab } from './types.js' @@ -1563,6 +1568,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d + transactionChannel, + poolConnectChannel, + watched, ++ run, + traceAsync, + transactionContext, + transactionSession, @@ -1576,7 +1582,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d Object.assign(Postgres, { PostgresError, -@@ -51,6 +69,7 @@ function Postgres(a, b) { +@@ -51,6 +70,7 @@ function Postgres(a, b) { , subscribe = options.no_subscribe || Subscribe(Postgres, { ...options }) let ending = false @@ -1584,7 +1590,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d const queries = Queue() , connecting = Queue() -@@ -62,7 +81,7 @@ function Postgres(a, b) { +@@ -62,7 +82,7 @@ function Postgres(a, b) { , full = Queue() , queues = { connecting, reserved, closed, ended, open, busy, full } @@ -1593,7 +1599,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d const sql = Sql(handler) -@@ -75,16 +94,19 @@ function Postgres(a, b) { +@@ -75,16 +95,19 @@ function Postgres(a, b) { PostgresError, options, reserve, @@ -1614,7 +1620,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d Object.entries(options.types).reduce((acc, [name, type]) => { acc[name] = (x) => new Parameter(x, type.to) -@@ -128,7 +150,7 @@ function Postgres(a, b) { +@@ -128,7 +151,7 @@ function Postgres(a, b) { function file(path, args = [], options = {}) { arguments.length === 2 && !Array.isArray(args) && (options = args, args = []) @@ -1623,7 +1629,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d fs.readFile(path, 'utf8', (err, string) => { if (err) return query.reject(err) -@@ -136,7 +158,9 @@ function Postgres(a, b) { +@@ -136,7 +159,9 @@ function Postgres(a, b) { query.strings = [string] handler(query) }) @@ -1634,7 +1640,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d ...options, simple: 'simple' in options ? options.simple : args.length === 0 }) -@@ -144,6 +168,56 @@ function Postgres(a, b) { +@@ -144,6 +169,56 @@ function Postgres(a, b) { } } @@ -1691,7 +1697,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d async function listen(name, fn, onlisten) { const listener = { fn, onlisten } -@@ -153,6 +227,7 @@ function Postgres(a, b) { +@@ -153,6 +228,7 @@ function Postgres(a, b) { idle_timeout: null, max_lifetime: null, fetch_types: false, @@ -1699,7 +1705,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d onclose() { Object.entries(listen.channels).forEach(([name, { listeners }]) => { delete listen.channels[name] -@@ -160,7 +235,7 @@ function Postgres(a, b) { +@@ -160,7 +236,7 @@ function Postgres(a, b) { }) }, onnotify(c, x) { @@ -1708,7 +1714,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d } })) -@@ -170,7 +245,7 @@ function Postgres(a, b) { +@@ -170,7 +246,7 @@ function Postgres(a, b) { if (exists) { channels[name].listeners.push(listener) const result = await channels[name].result @@ -1717,7 +1723,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d return { state: result.state, unlisten } } -@@ -178,7 +253,7 @@ function Postgres(a, b) { +@@ -178,7 +254,7 @@ function Postgres(a, b) { sql.unsafe('"' + name.replace(/"/g, '""') + '"') }`, listeners: [listener] } const result = await channels[name].result @@ -1726,7 +1732,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d return { state: result.state, unlisten } async function unlisten() { -@@ -200,62 +275,170 @@ function Postgres(a, b) { +@@ -200,62 +276,170 @@ function Postgres(a, b) { return await sql`select pg_notify(${ channel }, ${ '' + payload })` } @@ -1792,12 +1798,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d + : (move(c, full), q.queue = l.queue, l.queue.push(q)) + : q.reject(leaseError(l)) + } - -- move(c, reserved) -- c.reserved = () => queue.length -- ? c.execute(queue.shift()) -- : move(c, reserved) -- c.reserved.release = true ++ + function leaseError(l) { + return l.state === Lease.Closed + ? Errors.connection('CONNECTION_CLOSED', options) @@ -1809,12 +1810,17 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d + function stop(l, state) { + if (l.state === Lease.Released || l.state === Lease.Ended) + return null -+ + +- move(c, reserved) +- c.reserved = () => queue.length +- ? c.execute(queue.shift()) +- : move(c, reserved) +- c.reserved.release = true + const lost = l.state === Lease.Closed + l.state = state + while (l.queue.length) + l.queue.shift().reject(leaseError(l)) -+ + + poolReleased(l.connection, lost ? l.error : l.outcome) + const c = l.connection + if (c.owner !== l) @@ -1823,7 +1829,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d + c.owner = null + return c + } - ++ + async function reserve() { + const l = await lease(false) const sql = Sql(handler) @@ -1900,15 +1906,15 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d + } + release() + return result -+ } -+ -+ function release() { -+ const c = stop(l, Lease.Ended) -+ c && c.release() } - async function scope(c, fn, name) { - const sql = Sql(handler) ++ function release() { ++ const c = stop(l, Lease.Ended) ++ c && c.release() ++ } ++ + function terminate() { + const c = stop(l, Lease.Ended) + c && c.terminate() @@ -1933,7 +1939,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d try { result = await new Promise((resolve, reject) => { const x = fn(sql) -@@ -265,17 +448,30 @@ function Postgres(a, b) { +@@ -265,17 +449,30 @@ function Postgres(a, b) { if (uncaughtError) throw uncaughtError } catch (e) { @@ -1970,7 +1976,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d } return result -@@ -285,23 +481,21 @@ function Postgres(a, b) { +@@ -285,23 +482,21 @@ function Postgres(a, b) { return savepoint(sql => sql.apply(sql, arguments)) arguments.length === 1 && (fn = name, name = null) @@ -2003,7 +2009,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d } } -@@ -310,7 +504,7 @@ function Postgres(a, b) { +@@ -310,7 +505,7 @@ function Postgres(a, b) { queue.push(c) c.queue = queue queue === open @@ -2012,28 +2018,40 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d : c.idleTimer.cancel() return c } -@@ -330,15 +524,51 @@ function Postgres(a, b) { +@@ -327,18 +522,64 @@ function Postgres(a, b) { + } + + function handler(query) { ++ const refusal = query.trace && watched(poolConnectChannel) ++ ? run(poolConnectChannel, query.poolWait = poolContext(stats()), route, query) ++ : route(query) ++ refusal && query.reject(refusal) ++ } ++ ++ function route(query) { if (ending) - return query.reject(Errors.connection('CONNECTION_ENDED', options, options)) +- return query.reject(Errors.connection('CONNECTION_ENDED', options, options)) ++ return Errors.connection('CONNECTION_ENDED', options, options) - if (open.length) - return go(open.shift(), query) + const idle = takeOpen() + if (idle) -+ return go(idle, query) ++ return (go(idle, query), null) if (closed.length) - return connect(closed.shift(), query) - -- busy.length -- ? go(busy.shift(), query) -- : queries.push(query) +- return connect(closed.shift(), query) ++ return (query.poolWait && (query.fresh = true), connect(closed.shift(), query), null) ++ + const c = takeBusy() -+ c -+ ? go(c, query) -+ : allHeldByCopy() -+ ? query.reject(copyInProgress()) -+ : queries.push(query) ++ if (c) ++ return (go(c, query), null) ++ ++ if (allHeldByCopy()) ++ return copyInProgress() ++ ++ queries.push(query) ++ return null + } + + function allHeldByCopy() { @@ -2049,7 +2067,10 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d + const c = busy.shift() + if (c.accepting) + return c -+ + +- busy.length +- ? go(busy.shift(), query) +- : queries.push(query) + move(c, full) + } + @@ -2069,7 +2090,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d } function go(c, query) { -@@ -348,17 +578,18 @@ function Postgres(a, b) { +@@ -348,17 +589,18 @@ function Postgres(a, b) { } function cancel(query) { @@ -2098,7 +2119,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d }) } -@@ -368,13 +599,23 @@ function Postgres(a, b) { +@@ -368,13 +610,23 @@ function Postgres(a, b) { await 1 let timer @@ -2129,7 +2150,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d } async function close() { -@@ -382,9 +623,9 @@ function Postgres(a, b) { +@@ -382,9 +634,9 @@ function Postgres(a, b) { } async function destroy(resolve) { @@ -2140,7 +2161,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d resolve() } -@@ -398,12 +639,18 @@ function Postgres(a, b) { +@@ -398,12 +650,18 @@ function Postgres(a, b) { move(c, ended) } @@ -2162,7 +2183,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d while (ready && queries.length && max-- > 0) { const query = queries.shift() -@@ -418,12 +665,60 @@ function Postgres(a, b) { +@@ -418,12 +676,60 @@ function Postgres(a, b) { : move(c, full) } @@ -2227,7 +2248,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d } } -@@ -453,16 +748,17 @@ function parseOptions(a, b) { +@@ -453,16 +759,17 @@ function parseOptions(a, b) { connect_timeout : 30, max_lifetime : max_lifetime, max_pipeline : 100, @@ -2247,7 +2268,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d host : Array.isArray(host) ? host : host.split(',').map(x => x.split(':')[0]), port : Array.isArray(port) ? port : host.split(',').map(x => parseInt(x.split(':')[1] || port)), path : o.path || host.indexOf('/') > -1 && host + '/.s.PGSQL.' + port, -@@ -472,8 +768,8 @@ function parseOptions(a, b) { +@@ -472,8 +779,8 @@ function parseOptions(a, b) { ...Object.entries(defaults).reduce( (acc, [k, d]) => { const value = k in o ? o[k] : k in query @@ -2258,7 +2279,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d acc[k] = typeof value === 'string' && ints.includes(k) ? +value : value -@@ -488,16 +784,26 @@ function parseOptions(a, b) { +@@ -488,16 +795,26 @@ function parseOptions(a, b) { }, types : o.types || {}, target_session_attrs: tsa(o, url, env), @@ -2286,7 +2307,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d } function tsa(o, url, env) { -@@ -508,8 +814,12 @@ function tsa(o, url, env) { +@@ -508,8 +825,12 @@ function tsa(o, url, env) { throw new Error('target_session_attrs ' + x + ' is not supported') } @@ -2302,7 +2323,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..a554f987f17b08d975807dbdfcb3f79d function max_lifetime() { diff --git a/src/query.js b/src/query.js -index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..4d25903697001cf4c04c4169706cc2c8716c592d 100644 +index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..73f6efd93ecf7bf73c630680f4f40871aff71c10 100644 --- a/src/query.js +++ b/src/query.js @@ -1,3 +1,5 @@ @@ -2311,7 +2332,7 @@ index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..4d25903697001cf4c04c4169706cc2c8 const originCache = new Map() , originStackCache = new Map() , originError = Symbol('OriginError') -@@ -23,12 +25,15 @@ export class Query extends Promise { +@@ -23,12 +25,17 @@ export class Query extends Promise { this.state = null this.statement = null @@ -2326,10 +2347,12 @@ index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..4d25903697001cf4c04c4169706cc2c8 this.cancelled = null this.executed = false + this.trace = undefined ++ this.poolWait = undefined ++ this.fresh = false this.signature = '' this[originError] = this.handler.debug -@@ -50,7 +55,14 @@ export class Query extends Promise { +@@ -50,7 +57,14 @@ export class Query extends Promise { } cancel() { @@ -2345,7 +2368,7 @@ index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..4d25903697001cf4c04c4169706cc2c8 } simple() { -@@ -96,8 +108,8 @@ export class Query extends Promise { +@@ -96,8 +110,8 @@ export class Query extends Promise { resolve({ value, done: false }) return new Promise(r => prev = r) } @@ -2356,7 +2379,7 @@ index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..4d25903697001cf4c04c4169706cc2c8 }) this.execute() return promise -@@ -137,7 +149,15 @@ export class Query extends Promise { +@@ -137,7 +151,15 @@ export class Query extends Promise { } async handle() { @@ -2373,7 +2396,7 @@ index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..4d25903697001cf4c04c4169706cc2c8 } execute() { -@@ -161,6 +181,11 @@ export class Query extends Promise { +@@ -161,6 +183,11 @@ export class Query extends Promise { } } @@ -2701,11 +2724,12 @@ index 0000000000000000000000000000000000000000..bcdf4c1894e0f84957d7ff3890bea9d8 +} diff --git a/src/tracing.js b/src/tracing.js new file mode 100644 -index 0000000000000000000000000000000000000000..3de24120abd1577f11186ce01e93b6325eae5af0 +index 0000000000000000000000000000000000000000..cc9725578b8d5e8a47022f403eccc19b3bc1cfc1 --- /dev/null +++ b/src/tracing.js -@@ -0,0 +1,128 @@ +@@ -0,0 +1,150 @@ +import dc from 'diagnostics_channel' ++import { performance } from 'perf_hooks' + +const supported = Boolean(dc) && typeof dc.tracingChannel === 'function' + @@ -2713,7 +2737,6 @@ index 0000000000000000000000000000000000000000..3de24120abd1577f11186ce01e93b632 +export const connectChannel = supported ? dc.tracingChannel('postgres:connect') : undefined +export const transactionChannel = supported ? dc.tracingChannel('postgres:transaction') : undefined +export const poolConnectChannel = supported ? dc.tracingChannel('postgres:pool:connect') : undefined -+export const sendChannel = supported ? dc.channel('postgres:query:send') : undefined +export const poolReleaseChannel = supported ? dc.channel('postgres:pool:release') : undefined +export const poolRemoveChannel = supported ? dc.channel('postgres:pool:remove') : undefined + @@ -2793,9 +2816,30 @@ index 0000000000000000000000000000000000000000..3de24120abd1577f11186ce01e93b632 +} + +export function poolAcquired(context, processID, reused) { ++ context.handoverTime = performance.timeOrigin + performance.now() ++ context.queuedBehind = 0 + context.client = { processID, reused } +} + ++export function poolHandover(q, queuedBehind, session) { ++ const context = q.poolWait ++ context.handoverTime = performance.timeOrigin + performance.now() ++ context.queuedBehind = queuedBehind ++ context.client = { processID: session.processID, reused: !q.fresh } ++} ++ ++export function poolStarted(q) { ++ const context = q.poolWait ++ q.poolWait = undefined ++ resolved(poolConnectChannel, context) ++} ++ ++function poolFailed(q, error) { ++ const context = q.poolWait ++ q.poolWait = undefined ++ rejected(poolConnectChannel, context, error) ++} ++ +export function poolReleased(connection, error) { + if (poolReleaseChannel && poolReleaseChannel.hasSubscribers) + poolReleaseChannel.publish({ client: { processID: connection.session.processID }, error }) @@ -2810,18 +2854,18 @@ index 0000000000000000000000000000000000000000..3de24120abd1577f11186ce01e93b632 + return run(queryChannel, q.trace, fn, q) +} + -+export function querySent(q, session) { ++export function queryBuilt(q, session) { + const context = q.trace + context.query.text = q.string + context.query.name = q.statement.name || undefined + Object.assign(context.client, session) -+ sendChannel.hasSubscribers && sendChannel.publish(context) +} + +export function querySettled(q, result) { + if (q.streaming && result && typeof result.pipe === 'function') + return + ++ q.poolWait && poolStarted(q) + const context = q.trace + q.trace = undefined + context.result = result @@ -2829,6 +2873,7 @@ index 0000000000000000000000000000000000000000..3de24120abd1577f11186ce01e93b632 +} + +export function queryFailed(q, error) { ++ q.poolWait && poolFailed(q, error) + const context = q.trace + q.trace = undefined + rejected(queryChannel, context, error) @@ -3063,7 +3108,7 @@ index 0000000000000000000000000000000000000000..e8a4bddf7c0f4b6c55ec7c5f33865888 + } +} diff --git a/types/index.d.ts b/types/index.d.ts -index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..2c07940cd38d4af902b74959d7a28d144744b424 100644 +index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..3c4b90225bb42c28f3a00484bb67a8c4f0dd3f68 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,4 +1,4 @@ @@ -3245,7 +3290,7 @@ index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..2c07940cd38d4af902b74959d7a28d14 } interface TransactionSql = {}> extends ISql { -@@ -727,8 +798,91 @@ declare namespace postgres { +@@ -727,8 +798,95 @@ declare namespace postgres { prepare(name: string): Promise>; } @@ -3281,7 +3326,7 @@ index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..2c07940cd38d4af902b74959d7a28d14 + error?: Error; + } + -+ /** Context of the `postgres:pool:connect` tracing channel, for `sql.reserve()` and `sql.begin()`. */ ++ /** Context of the `postgres:pool:connect` tracing channel: every wait for a connection, for `sql.reserve()`, `sql.begin()` and plain pooled queries. */ + interface PoolConnectTraceContext { + pool: { + totalCount: number; @@ -3289,6 +3334,10 @@ index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..2c07940cd38d4af902b74959d7a28d14 + waitingCount: number; + maxSize: number; + }; ++ /** Epoch milliseconds at which the connection took the call over; set before `asyncStart` on success. `handoverTime` minus the `start` time is the wait for a connection. */ ++ handoverTime?: number; ++ /** Queries the connection already had running or waiting when it took this one over; 0 for a lease. The time from `handoverTime` to `asyncStart` is spent behind them. */ ++ queuedBehind?: number; + /** Set before `asyncStart` on success */ + client?: { + processID: number | undefined; From c0f12298276b61a7f2c0b7057aaf66a00a0add4b Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 23:52:28 +0100 Subject: [PATCH 22/34] fix: publish the end of a pool span after its start when the connection is idle A query that finds an idle connection becomes active inside the routing, so the pool span settled before it published end. The settlement is held until end has been published, which gives the order of tracePromise. --- src/index.js | 4 ++-- src/query.js | 1 + src/tracing.js | 20 ++++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index 3f1048a4..9876f12e 100644 --- a/src/index.js +++ b/src/index.js @@ -32,7 +32,7 @@ import { transactionChannel, poolConnectChannel, watched, - run, + traceRoute, traceAsync, transactionContext, transactionSession, @@ -523,7 +523,7 @@ function Postgres(a, b) { function handler(query) { const refusal = query.trace && watched(poolConnectChannel) - ? run(poolConnectChannel, query.poolWait = poolContext(stats()), route, query) + ? traceRoute(query, poolContext(stats()), route) : route(query) refusal && query.reject(refusal) } diff --git a/src/query.js b/src/query.js index 73f6efd9..0141d3c8 100644 --- a/src/query.js +++ b/src/query.js @@ -36,6 +36,7 @@ export class Query extends Promise { this.trace = undefined this.poolWait = undefined this.fresh = false + this.poolState = 0 this.signature = '' this[originError] = this.handler.debug diff --git a/src/tracing.js b/src/tracing.js index cc972557..7c389412 100644 --- a/src/tracing.js +++ b/src/tracing.js @@ -98,7 +98,27 @@ export function poolHandover(q, queuedBehind, session) { context.client = { processID: session.processID, reused: !q.fresh } } +export function traceRoute(q, context, fn) { + q.poolWait = context + q.poolState = 1 + let refusal + try { + refusal = run(poolConnectChannel, context, fn, q) + } finally { + const early = q.poolState === 2 + q.poolState = 0 + early && poolStarted(q) + } + + return refusal +} + export function poolStarted(q) { + if (q.poolState === 1) { + q.poolState = 2 + return + } + const context = q.poolWait q.poolWait = undefined resolved(poolConnectChannel, context) From d11141014beade973caf37189bb86670b0d6f63f Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sat, 24 Oct 2026 23:56:07 +0100 Subject: [PATCH 23/34] test: queue the cancelled query behind a barrier instead of racing the connection --- tests/tracing.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/tracing.js b/tests/tracing.js index 6646ed86..be6da38c 100644 --- a/tests/tracing.js +++ b/tests/tracing.js @@ -641,8 +641,9 @@ t('Tracing fails the pool span of a query refused during a COPY', async() => { t('Tracing fails the pool span of a query cancelled in the pool queue', async() => { const sql = postgres(options) + await sql`select 1` const { events, stop } = collect('query', 'pool:connect') - const first = sql`select pg_sleep(0.1)`.execute() + const first = sql`select pg_sleep(${ 0.1 })`.execute() const waiting = sql`select 2 as x` const code = waiting.then(() => 'resolved', e => e.code) await delay(10) From 4ace8a8b4cda34f25b5f87b4220c56bf02fd6aa1 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sun, 25 Oct 2026 00:10:59 +0100 Subject: [PATCH 24/34] fix: settle a pool span once when the query fails to build while it is routed A query that failed to build while the pool routed it onto an idle connection settled its pool span twice, the second time with no context, and a build failure on a busy connection published the pool span's error before its end. While the routing is in progress the query's failure is now held, and after the span's end the pool span is resolved if the query was activated or failed otherwise, followed by the query's own failure. The state of the wait lives on a private object, so an untraced query carries only trace and poolWait. A lease now reports the queries its connection really had in flight, and a queued query that a reconnect hands to connect() counts as having opened the connection. --- src/connection.js | 7 +++++- src/index.js | 8 ++++--- src/query.js | 2 -- src/tracing.js | 58 ++++++++++++++++++++++++++++++----------------- 4 files changed, 48 insertions(+), 27 deletions(-) diff --git a/src/connection.js b/src/connection.js index 2bf865a1..2522150c 100644 --- a/src/connection.js +++ b/src/connection.js @@ -260,6 +260,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose get pipelined() { return sent.length }, get accepting() { return accepting() }, get session() { return session() }, + get inflight() { return inflight() }, get copying() { return stream !== null }, get copyHeld() { return stream !== null && !stream.writableEnded }, get acquiring() { return acquisition && !acquisition.owner.cancelled ? 1 : 0 }, @@ -318,6 +319,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose return options.path || host[serverIndex] } + function inflight() { + return (query ? 1 : 0) + sent.length + } + function session() { return { host: serverAddress(), port: serverPort(), ssl: secured, processID: backend.pid } } @@ -591,7 +596,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose return true q.owner === undefined && (q.owner = connection.owner) - q.poolWait && poolHandover(q, (query ? 1 : 0) + sent.length, session()) + q.poolWait && poolHandover(q, inflight(), session()) try { q.state = backend diff --git a/src/index.js b/src/index.js index 9876f12e..271317af 100644 --- a/src/index.js +++ b/src/index.js @@ -288,7 +288,7 @@ function Postgres(a, b) { if (ending) throw Errors.connection('CONNECTION_ENDED', options) - const l = { state: null, connection: null, error: null, outcome: undefined, onclose: null, queue: Queue() } + const l = { state: null, connection: null, error: null, outcome: undefined, behind: 0, onclose: null, queue: Queue() } let reused = true const idle = takeOpen() idle @@ -303,12 +303,13 @@ function Postgres(a, b) { ? reject(copyInProgress()) : queries.push(query) }) - trace && poolAcquired(trace, l.connection.session.processID, reused) + trace && poolAcquired(trace, l.connection.session.processID, reused, l.behind) return l } function own(c, l) { l.connection = c + l.behind = c.inflight l.state = Lease.Active c.owner = l l.next = () => { @@ -537,7 +538,7 @@ function Postgres(a, b) { return (go(idle, query), null) if (closed.length) - return (query.poolWait && (query.fresh = true), connect(closed.shift(), query), null) + return (connect(closed.shift(), query), null) const c = takeBusy() if (c) @@ -641,6 +642,7 @@ function Postgres(a, b) { } function connect(c, query) { + query.poolWait && (query.poolWait.fresh = true) move(c, connecting) c.connect(query) return c diff --git a/src/query.js b/src/query.js index 0141d3c8..575c6014 100644 --- a/src/query.js +++ b/src/query.js @@ -35,8 +35,6 @@ export class Query extends Promise { this.executed = false this.trace = undefined this.poolWait = undefined - this.fresh = false - this.poolState = 0 this.signature = '' this[originError] = this.handler.debug diff --git a/src/tracing.js b/src/tracing.js index 7c389412..8fb3bc73 100644 --- a/src/tracing.js +++ b/src/tracing.js @@ -85,49 +85,57 @@ export function poolContext({ totalCount, idleCount, waitingCount, max }) { return { pool: { totalCount, idleCount, waitingCount, maxSize: max } } } -export function poolAcquired(context, processID, reused) { +export function poolAcquired(context, processID, reused, queuedBehind) { context.handoverTime = performance.timeOrigin + performance.now() - context.queuedBehind = 0 + context.queuedBehind = queuedBehind context.client = { processID, reused } } export function poolHandover(q, queuedBehind, session) { - const context = q.poolWait - context.handoverTime = performance.timeOrigin + performance.now() - context.queuedBehind = queuedBehind - context.client = { processID: session.processID, reused: !q.fresh } + const wait = q.poolWait + wait.context.handoverTime = performance.timeOrigin + performance.now() + wait.context.queuedBehind = queuedBehind + wait.context.client = { processID: session.processID, reused: !wait.fresh } } export function traceRoute(q, context, fn) { - q.poolWait = context - q.poolState = 1 - let refusal + const wait = q.poolWait = { context, fresh: false, routing: true, activated: false, failure: undefined } try { - refusal = run(poolConnectChannel, context, fn, q) + return run(poolConnectChannel, context, fn, q) } finally { - const early = q.poolState === 2 - q.poolState = 0 - early && poolStarted(q) + wait.routing = false + settleRouted(q, wait) } +} + +function settleRouted(q, wait) { + if (q.poolWait !== wait || !(wait.activated || wait.failure)) + return - return refusal + q.poolWait = undefined + if (wait.activated) + resolved(poolConnectChannel, wait.context) + else + rejected(poolConnectChannel, wait.context, wait.failure.error) + + wait.failure && queryFailed(q, wait.failure.error) } export function poolStarted(q) { - if (q.poolState === 1) { - q.poolState = 2 + const wait = q.poolWait + if (wait.routing) { + wait.activated = true return } - const context = q.poolWait q.poolWait = undefined - resolved(poolConnectChannel, context) + resolved(poolConnectChannel, wait.context) } function poolFailed(q, error) { - const context = q.poolWait + const wait = q.poolWait q.poolWait = undefined - rejected(poolConnectChannel, context, error) + rejected(poolConnectChannel, wait.context, error) } export function poolReleased(connection, error) { @@ -163,7 +171,15 @@ export function querySettled(q, result) { } export function queryFailed(q, error) { - q.poolWait && poolFailed(q, error) + if (q.poolWait) { + if (q.poolWait.routing) { + q.poolWait.failure = q.poolWait.failure || { error } + return + } + + poolFailed(q, error) + } + const context = q.trace q.trace = undefined rejected(queryChannel, context, error) From dd4d4a80f3e58639a243385d2dc1a958d74fc48b Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sun, 25 Oct 2026 00:10:59 +0100 Subject: [PATCH 25/34] test: cover build failures while routing, lease queues and statements on leases Adds the build-failure cases on an idle and on a pipelining connection with subscribers that dereference the context, a lease taken on a busy connection, and statements inside begin and reserve publishing no plain pool span. The lost-connection test now checks the pool span ends before its query, and the tests that raced a delay against a sleeping query wait for the routing with setImmediate instead. --- tests/tracing.js | 75 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/tests/tracing.js b/tests/tracing.js index be6da38c..fd541940 100644 --- a/tests/tracing.js +++ b/tests/tracing.js @@ -4,8 +4,9 @@ import { AsyncLocalStorage } from 'async_hooks' import { t } from './test.js' import postgres from '../src/index.js' +const snapshot = context => Object.fromEntries(Object.entries(context)) +const settle = () => new Promise(setImmediate) const now = () => performance.timeOrigin + performance.now() -const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) const rel = x => new URL(x, import.meta.url) const stages = ['start', 'end', 'asyncStart', 'asyncEnd', 'error'] @@ -23,7 +24,7 @@ function collect(...names) { const ids = new Map() const id = context => ids.get(context) || (ids.set(context, ids.size + 1), ids.size) for (const name of names.filter(x => x.startsWith('postgres:'))) { - const handler = context => events.push({ name, stage: 'publish', id: id(context), time: now(), context: { ...context } }) + const handler = context => events.push({ name, stage: 'publish', id: id(context), time: now(), context: snapshot(context) }) dc.subscribe(name, handler) undo.push(() => dc.unsubscribe(name, handler)) } @@ -31,7 +32,7 @@ function collect(...names) { for (const name of names.filter(x => !x.startsWith('postgres:'))) { const channel = dc.tracingChannel('postgres:' + name) const handlers = Object.fromEntries(stages.map(stage => - [stage, context => events.push({ name, stage, id: id(context), time: now(), context: { ...context } })] + [stage, context => events.push({ name, stage, id: id(context), time: now(), context: snapshot(context) })] )) channel.subscribe(handlers) undo.push(() => channel.unsubscribe(handlers)) @@ -572,7 +573,7 @@ t('Tracing a query sent ahead to a busy connection ends its pool span when the e await sql`select 1` const { events, stop } = collect('query', 'pool:connect') const slow = sql`select pg_sleep(0.2)`.execute() - await delay(50) + await settle() const waiting = sql`select 2 as x` await Promise.all([slow, waiting]) stop() @@ -646,7 +647,7 @@ t('Tracing fails the pool span of a query cancelled in the pool queue', async() const first = sql`select pg_sleep(${ 0.1 })`.execute() const waiting = sql`select 2 as x` const code = waiting.then(() => 'resolved', e => e.code) - await delay(10) + await settle() await waiting.cancel() await first const result = await code @@ -681,18 +682,20 @@ t('Tracing fails the pool span of a query that waited behind others on a connect const [{ pid }] = await sql`select pg_backend_pid() as pid` const { events, stop } = collect('query', 'pool:connect') const running = sql`select pg_sleep(5)`.execute() - await delay(50) + await settle() const waiting = sql`select 2 as x`.execute() await killer`select pg_terminate_backend(${ pid })` const outcomes = await Promise.allSettled([running, waiting]) stop() await Promise.all([sql.end(), killer.end()]) const failures = of(events, 'pool:connect').filter(x => x.stage === 'error') + const failed_ = events.find(x => x.name === 'pool:connect' && x.stage === 'asyncEnd' && x.context.error) return [ - JSON.stringify(['rejected', 'rejected', 1, true]), + JSON.stringify(['rejected', 'rejected', 1, true, true]), JSON.stringify([ outcomes[0].status, outcomes[1].status, failures.length, - count(events, 'start', 'pool:connect') === count(events, 'asyncEnd', 'pool:connect') + count(events, 'start', 'pool:connect') === count(events, 'asyncEnd', 'pool:connect'), + events.indexOf(failed_) < asyncEndOf(events, 'select 2 as x') ]) ] }) @@ -719,3 +722,59 @@ t('Tracing opens no pool span when only one of the query and pool channels has a } return [JSON.stringify([0, []]), JSON.stringify([poolOnly.events.length, published])] }) + +t('Tracing a query that fails to build while it is routed settles its pool span once, after end', async() => { + const sql = postgres(options) + await sql`select 1` + const idle = collect('query', 'pool:connect') + await sql`select ${ undefined }`.catch(() => undefined) + idle.stop() + const slow = sql`select pg_sleep(0.2)`.execute() + await settle() + const busy = collect('query', 'pool:connect') + await sql`select ${ undefined }`.catch(() => undefined) + busy.stop() + await slow + await sql.end() + const summary = events => [ + sequence(events, 'pool:connect'), sequence(events, 'query'), count(events, 'asyncEnd', 'pool:connect'), count(events, 'asyncEnd'), + at(events, 'pool:connect', 'asyncEnd') < at(events, 'query', 'asyncEnd') + ] + return [ + JSON.stringify([[plain, failed, 1, 1, true], [failed, failed, 1, 1, true]]), + JSON.stringify([summary(idle.events), summary(busy.events)]) + ] +}) + +t('Tracing statements inside begin and reserve publish no plain pool span', async() => { + const sql = postgres(options) + const { events, stop } = collect('query', 'pool:connect') + await sql.begin(async sql => { + await sql`select 1` + }) + const reserved = await sql.reserve() + await reserved`select 2` + reserved.release() + stop() + await sql.end() + return [ + JSON.stringify([2, 2, 4]), + JSON.stringify([count(events, 'start', 'pool:connect'), count(events, 'asyncEnd', 'pool:connect'), count(events, 'start')]) + ] +}) + +t('Tracing a lease taken on a busy connection reports the queries ahead of it', async() => { + const sql = postgres(options) + await sql`select 1` + const slow = sql`select pg_sleep(0.2)`.execute() + await settle() + const { events, stop } = collect('pool:connect') + await sql.begin(async sql => { + await sql`select 1` + }) + stop() + await slow + await sql.end() + const [span] = spans(events) + return [JSON.stringify([1, true]), JSON.stringify([span.queuedBehind, span.client.reused])] +}) From 928b562b4d2ce606e9274f987868860a0adf3aa9 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sun, 25 Oct 2026 00:10:59 +0100 Subject: [PATCH 26/34] docs: word the hand-over fields for waits that fail after the hand-over handoverTime, queuedBehind and client are set once a connection took the call over, which also covers a query that fails after that. The OpenTelemetry example skips waits that never had a hand-over. --- docs/api.md | 11 +++++++---- types/index.d.ts | 6 +++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/api.md b/docs/api.md index d3c6f2d3..910c0f5c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1589,10 +1589,10 @@ The text is known only after the driver has serialized the query for a connectio |---|---| | `pool.totalCount`, `pool.idleCount`, `pool.waitingCount` | at `start`, as `sql.stats()` reports them | | `pool.maxSize` | at `start`: the `max` option | -| `handoverTime` | before `asyncStart` on success: the epoch milliseconds at which the connection took the call over | -| `queuedBehind` | before `asyncStart` on success: how many queries the connection already had running or waiting when it took a plain query over; 0 for `sql.reserve()` and `sql.begin()` | -| `client.processID` | before `asyncStart` on success | -| `client.reused` | before `asyncStart` on success: false when the acquisition had to open the connection | +| `handoverTime` | before `asyncStart`, once a connection took the call over: the epoch milliseconds at which it did | +| `queuedBehind` | with `handoverTime`: how many queries the connection already had running or waiting when it took the call over | +| `client.processID` | with `handoverTime` | +| `client.reused` | with `handoverTime`: false when the wait included opening the connection | | `error` | before `error`: a connection error, `CONNECTION_ENDED` after `sql.end()`, `COPY_IN_PROGRESS`, or the cancellation or connection error of a plain query that settled before it started | `handoverTime` minus the time of `start` is the wait for a connection, which is what OpenTelemetry's `db.client.connection.wait_time` measures. When `queuedBehind` is above 0, the time from `handoverTime` to `asyncStart` was spent behind those queries on the same connection. Take the `start` time yourself in the `start` subscriber. On Cloudflare Workers `performance.now()` advances only after I/O, so within a request both times can be equal. @@ -1610,6 +1610,9 @@ pool.subscribe({ end() {}, asyncStart() {}, asyncEnd(context) { + if (context.handoverTime === undefined) + return + context.span?.addEvent('connection handed over', { 'postgres.queued_behind': context.queuedBehind }, context.handoverTime) }, error() {} diff --git a/types/index.d.ts b/types/index.d.ts index 3c4b9022..394a76b1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -838,11 +838,11 @@ declare namespace postgres { waitingCount: number; maxSize: number; }; - /** Epoch milliseconds at which the connection took the call over; set before `asyncStart` on success. `handoverTime` minus the `start` time is the wait for a connection. */ + /** Epoch milliseconds at which the connection took the call over; set before `asyncStart` once a connection took the call over. `handoverTime` minus the `start` time is the wait for a connection. */ handoverTime?: number; - /** Queries the connection already had running or waiting when it took this one over; 0 for a lease. The time from `handoverTime` to `asyncStart` is spent behind them. */ + /** Queries the connection already had running or waiting when it took this one over. The time from `handoverTime` to `asyncStart` is spent behind them. */ queuedBehind?: number; - /** Set before `asyncStart` on success */ + /** Set with `handoverTime` */ client?: { processID: number | undefined; /** False when the acquisition had to open the connection */ From 0adf5d90a47430b0fe791fe292eb3eb2f8d932df Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sun, 25 Oct 2026 00:11:00 +0100 Subject: [PATCH 27/34] docs: refresh the diff since 3.4.9 --- docs/changes-since-3.4.9.diff | 220 ++++++++++++++++++++-------------- 1 file changed, 133 insertions(+), 87 deletions(-) diff --git a/docs/changes-since-3.4.9.diff b/docs/changes-since-3.4.9.diff index 7d068a4c..4bd7e5d2 100644 --- a/docs/changes-since-3.4.9.diff +++ b/docs/changes-since-3.4.9.diff @@ -1,5 +1,5 @@ diff --git a/src/connection.js b/src/connection.js -index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c1768d4c69db 100644 +index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2522150c9db4dfaa6553587ee54c3cc5e63f7fd3 100644 --- a/src/connection.js +++ b/src/connection.js @@ -4,24 +4,55 @@ import crypto from 'crypto' @@ -97,7 +97,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 , errorResponse = null , result = new Result() , incoming = Buffer.alloc(0) -@@ -86,102 +125,509 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -86,102 +125,514 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose , statements = {} , statementId = Math.random().toString(36).slice(2) , statementCount = 1 @@ -249,6 +249,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 + get pipelined() { return sent.length }, + get accepting() { return accepting() }, + get session() { return session() }, ++ get inflight() { return inflight() }, + get copying() { return stream !== null }, + get copyHeld() { return stream !== null && !stream.writableEnded }, + get acquiring() { return acquisition && !acquisition.owner.cancelled ? 1 : 0 }, @@ -320,6 +321,10 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 + return options.path || host[serverIndex] + } + ++ function inflight() { ++ return (query ? 1 : 0) + sent.length ++ } ++ + function session() { + return { host: serverAddress(), port: serverPort(), ssl: secured, processID: backend.pid } + } @@ -430,9 +435,9 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 + listen(socket) + } catch (err) { + afterFailure(err, 'protocol') -+ } -+ } -+ + } + } + + function authenticate() { + try { + statements = {} @@ -563,9 +568,9 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 + ms && (closeTimer = clampedTimeout(() => (closeTimer = null, dispatch('closeTimeout')), ms)) + } else { + socket.destroy() - } - } - ++ } ++ } ++ + function handoff() { + const { owner, ending, trace } = acquisition + transition(Phase.Ready) @@ -605,7 +610,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 + return true + + q.owner === undefined && (q.owner = connection.owner) -+ q.poolWait && poolHandover(q, (query ? 1 : 0) + sent.length, session()) ++ q.poolWait && poolHandover(q, inflight(), session()) try { q.state = backend @@ -654,7 +659,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 function toBuffer(q) { if (q.parameters.length >= 65534) throw Errors.generic('MAX_PARAMETERS_EXCEEDED', 'Max number of parameters (65534) exceeded') -@@ -206,7 +652,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -206,7 +657,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose function prepared(q) { return Buffer.concat([ @@ -663,7 +668,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 q.cursorFn ? Execute('', q.cursorRows) : ExecuteUnnamed -@@ -234,13 +680,17 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -234,13 +685,17 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose q.signature = q.prepare && types + string q.onlyDescribe && (delete statements[q.signature]) q.parameters = q.parameters || parameters @@ -683,7 +688,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 } function write(x, fn) { -@@ -252,52 +702,12 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -252,52 +707,12 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function nextWrite(fn) { @@ -737,7 +742,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 function data(x) { if (incomings) { incomings.push(x) -@@ -312,6 +722,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -312,6 +727,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose ? x : Buffer.concat([incoming, x], incoming.length + x.length) @@ -745,7 +750,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 while (incoming.length > 4) { length = incoming.readUInt32BE(1) if (length >= incoming.length) { -@@ -323,74 +734,34 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -323,74 +739,34 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose try { handle(incoming.subarray(0, length + 1)) } catch (e) { @@ -834,7 +839,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 } function queryError(query, err) { -@@ -401,7 +772,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -401,7 +777,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose err = new Error(err) 'query' in err || 'parameters' in err || Object.defineProperties(err, { @@ -843,7 +848,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 query: { value: query.string, enumerable: options.debug }, parameters: { value: query.parameters, enumerable: options.debug }, args: { value: query.args, enumerable: options.debug }, -@@ -410,51 +781,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -410,51 +786,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose query.reject(err) } @@ -903,7 +908,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 } /* Handlers */ -@@ -495,6 +829,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -495,6 +834,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose let column let value @@ -912,7 +917,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 const row = query.isRaw ? new Array(query.statement.columns.length) : {} for (let i = 0; i < query.statement.columns.length; i++) { column = query.statement.columns[i] -@@ -514,13 +850,13 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -514,13 +855,13 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose query.isRaw ? (row[i] = query.isRaw === true ? value @@ -930,7 +935,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 } function ParameterStatus(x) { -@@ -528,19 +864,25 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -528,19 +869,25 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose backendParameters[k] = v if (options.parameters[k] !== v) { options.parameters[k] = v @@ -958,7 +963,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 query.resolve(results || result) } } else if (errorResponse) { -@@ -549,42 +891,35 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -549,42 +896,35 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose query = results = errorResponse = null result = new Result() @@ -996,7 +1001,9 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 - Connection(options).cancel(query.state, query.cancelled.resolve, query.cancelled.reject) + return query + } -+ + +- if (query) +- return // Consider opening if able and sent.length < 50 + function initialized() { + if (target_session_attrs) { + if (!backendParameters.in_hot_standby || !backendParameters.default_transaction_read_only) @@ -1006,11 +1013,6 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 + return mismatched(reason) + } -- if (query) -- return // Consider opening if able and sent.length < 50 -+ if (needsTypes) -+ return fetchArrayTypes() - - connection.reserved - ? !connection.reserved.release && x[5] === 73 // I - ? ending @@ -1020,11 +1022,14 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 - : ending - ? terminate() - : onopen(connection) ++ if (needsTypes) ++ return fetchArrayTypes() ++ + handoff() } function CommandComplete(x) { -@@ -602,7 +937,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -602,7 +942,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose final && (final(), final = null) @@ -1033,7 +1038,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 return errored(Errors.generic('UNSAFE_TRANSACTION', 'Only use sql.begin, sql.reserved or max: 1')) if (query.options.simple) -@@ -654,7 +989,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -654,7 +994,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose const number = x.readUInt16BE(index + 4) const type = x.readUInt32BE(index + 6) query.statement.columns[i] = { @@ -1042,7 +1047,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 ? transform.column.from(x.toString('utf8', start, index - 1)) : x.toString('utf8', start, index - 1), parser: parsers[type], -@@ -670,8 +1005,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -670,8 +1010,9 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose return (query.resolve(query.statement), write(Sync)) } @@ -1054,7 +1059,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 type === 3 ? AuthenticationCleartextPassword : type === 5 ? AuthenticationMD5Password : type === 10 ? SASL : -@@ -679,18 +1015,20 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -679,18 +1020,20 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose type === 12 ? SASLFinal : type !== 0 ? UnknownAuth : noop @@ -1078,7 +1083,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 const payload = 'md5' + ( await md5( Buffer.concat([ -@@ -699,40 +1037,42 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -699,40 +1042,42 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose ]) ) ) @@ -1135,7 +1140,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 write( b().p().str(payload).end() ) -@@ -742,8 +1082,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -742,8 +1087,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose if (x.toString('utf8', 9).split(b.N, 1)[0].slice(2) === serverSignature) return /* c8 ignore next 5 */ @@ -1145,7 +1150,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 } function Pass() { -@@ -765,48 +1104,47 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -765,48 +1109,47 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose backend.secret = x.readUInt32BE(9) } @@ -1217,7 +1222,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 } function ErrorResponse(x) { -@@ -814,12 +1152,11 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -814,12 +1157,11 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose (query.cursorFn || query.describeFirst) && write(Sync) errorResponse = Errors.postgres(parseError(x)) } else { @@ -1231,7 +1236,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 q.retried = error execute(q) } -@@ -830,22 +1167,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -830,22 +1172,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose let index = 9 while (x[index++] !== 0); @@ -1265,7 +1270,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 } } -@@ -862,7 +1204,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -862,7 +1209,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose }, destroy(error, callback) { callback(error) @@ -1274,7 +1279,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 stream = null }, final(callback) { -@@ -872,27 +1214,29 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -872,27 +1219,29 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } }) query.resolve(stream) @@ -1307,7 +1312,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 stream = null }, final(callback) { -@@ -901,22 +1245,36 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -901,22 +1250,36 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } }) query.resolve(stream) @@ -1347,7 +1352,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 } /* c8 ignore next 3 */ -@@ -945,20 +1303,21 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -945,20 +1308,21 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } /* Messages */ @@ -1376,7 +1381,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 prev = b.i b.inc(4).str(x).i32(b.i - prev - 4, prev) -@@ -966,7 +1325,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -966,7 +1330,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose b.i16(0) @@ -1405,7 +1410,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 } function Parse(str, parameters, types, name = '') { -@@ -994,7 +1373,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -994,7 +1378,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function StartupMessage() { @@ -1414,7 +1419,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 Object.entries(Object.assign({ user, database, -@@ -1019,6 +1398,33 @@ function parseError(x) { +@@ -1019,6 +1403,33 @@ function parseError(x) { return error } @@ -1448,7 +1453,7 @@ index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..2bf865a14007c48ae896ea56c650c176 function md5(x) { return crypto.createHash('md5').update(x).digest('hex') } -@@ -1042,21 +1448,44 @@ function xor(a, b) { +@@ -1042,21 +1453,44 @@ function xor(a, b) { function timer(fn, seconds) { seconds = typeof seconds === 'function' ? seconds() : seconds if (!seconds) @@ -1534,7 +1539,7 @@ index 0ff83c428bec774e11417b8e1f033dc3305c18cf..023c3ba1bd113e6f5508ab079efcf220 code: x, errno: x, diff --git a/src/index.js b/src/index.js -index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf596fb01c 100644 +index c7fba3dace20173f4cb13f3f138bb6d213b43683..271317afcfa47c1d5557a0ca96bce9651f4467e9 100644 --- a/src/index.js +++ b/src/index.js @@ -3,10 +3,12 @@ import fs from 'fs' @@ -1568,7 +1573,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf + transactionChannel, + poolConnectChannel, + watched, -+ run, ++ traceRoute, + traceAsync, + transactionContext, + transactionSession, @@ -1732,7 +1737,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf return { state: result.state, unlisten } async function unlisten() { -@@ -200,62 +276,170 @@ function Postgres(a, b) { +@@ -200,62 +276,171 @@ function Postgres(a, b) { return await sql`select pg_notify(${ channel }, ${ '' + payload })` } @@ -1757,7 +1762,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf + if (ending) + throw Errors.connection('CONNECTION_ENDED', options) + -+ const l = { state: null, connection: null, error: null, outcome: undefined, onclose: null, queue: Queue() } ++ const l = { state: null, connection: null, error: null, outcome: undefined, behind: 0, onclose: null, queue: Queue() } + let reused = true + const idle = takeOpen() + idle @@ -1772,12 +1777,13 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf + ? reject(copyInProgress()) + : queries.push(query) + }) -+ trace && poolAcquired(trace, l.connection.session.processID, reused) ++ trace && poolAcquired(trace, l.connection.session.processID, reused, l.behind) + return l + } + + function own(c, l) { + l.connection = c ++ l.behind = c.inflight + l.state = Lease.Active + c.owner = l + l.next = () => { @@ -1939,7 +1945,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf try { result = await new Promise((resolve, reject) => { const x = fn(sql) -@@ -265,17 +449,30 @@ function Postgres(a, b) { +@@ -265,17 +450,30 @@ function Postgres(a, b) { if (uncaughtError) throw uncaughtError } catch (e) { @@ -1976,7 +1982,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf } return result -@@ -285,23 +482,21 @@ function Postgres(a, b) { +@@ -285,23 +483,21 @@ function Postgres(a, b) { return savepoint(sql => sql.apply(sql, arguments)) arguments.length === 1 && (fn = name, name = null) @@ -2009,7 +2015,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf } } -@@ -310,7 +505,7 @@ function Postgres(a, b) { +@@ -310,7 +506,7 @@ function Postgres(a, b) { queue.push(c) c.queue = queue queue === open @@ -2018,12 +2024,12 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf : c.idleTimer.cancel() return c } -@@ -327,18 +522,64 @@ function Postgres(a, b) { +@@ -327,18 +523,64 @@ function Postgres(a, b) { } function handler(query) { + const refusal = query.trace && watched(poolConnectChannel) -+ ? run(poolConnectChannel, query.poolWait = poolContext(stats()), route, query) ++ ? traceRoute(query, poolContext(stats()), route) + : route(query) + refusal && query.reject(refusal) + } @@ -2041,7 +2047,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf if (closed.length) - return connect(closed.shift(), query) -+ return (query.poolWait && (query.fresh = true), connect(closed.shift(), query), null) ++ return (connect(closed.shift(), query), null) + + const c = takeBusy() + if (c) @@ -2090,7 +2096,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf } function go(c, query) { -@@ -348,17 +589,18 @@ function Postgres(a, b) { +@@ -348,17 +590,18 @@ function Postgres(a, b) { } function cancel(query) { @@ -2119,7 +2125,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf }) } -@@ -368,13 +610,23 @@ function Postgres(a, b) { +@@ -368,13 +611,23 @@ function Postgres(a, b) { await 1 let timer @@ -2150,7 +2156,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf } async function close() { -@@ -382,9 +634,9 @@ function Postgres(a, b) { +@@ -382,13 +635,14 @@ function Postgres(a, b) { } async function destroy(resolve) { @@ -2161,7 +2167,12 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf resolve() } -@@ -398,12 +650,18 @@ function Postgres(a, b) { + function connect(c, query) { ++ query.poolWait && (query.poolWait.fresh = true) + move(c, connecting) + c.connect(query) + return c +@@ -398,12 +652,18 @@ function Postgres(a, b) { move(c, ended) } @@ -2183,7 +2194,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf while (ready && queries.length && max-- > 0) { const query = queries.shift() -@@ -418,12 +676,60 @@ function Postgres(a, b) { +@@ -418,12 +678,60 @@ function Postgres(a, b) { : move(c, full) } @@ -2248,7 +2259,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf } } -@@ -453,16 +759,17 @@ function parseOptions(a, b) { +@@ -453,16 +761,17 @@ function parseOptions(a, b) { connect_timeout : 30, max_lifetime : max_lifetime, max_pipeline : 100, @@ -2268,7 +2279,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf host : Array.isArray(host) ? host : host.split(',').map(x => x.split(':')[0]), port : Array.isArray(port) ? port : host.split(',').map(x => parseInt(x.split(':')[1] || port)), path : o.path || host.indexOf('/') > -1 && host + '/.s.PGSQL.' + port, -@@ -472,8 +779,8 @@ function parseOptions(a, b) { +@@ -472,8 +781,8 @@ function parseOptions(a, b) { ...Object.entries(defaults).reduce( (acc, [k, d]) => { const value = k in o ? o[k] : k in query @@ -2279,7 +2290,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf acc[k] = typeof value === 'string' && ints.includes(k) ? +value : value -@@ -488,16 +795,26 @@ function parseOptions(a, b) { +@@ -488,16 +797,26 @@ function parseOptions(a, b) { }, types : o.types || {}, target_session_attrs: tsa(o, url, env), @@ -2307,7 +2318,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf } function tsa(o, url, env) { -@@ -508,8 +825,12 @@ function tsa(o, url, env) { +@@ -508,8 +827,12 @@ function tsa(o, url, env) { throw new Error('target_session_attrs ' + x + ' is not supported') } @@ -2323,7 +2334,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..3f1048a4d1832515bd9dbfdc61705daf function max_lifetime() { diff --git a/src/query.js b/src/query.js -index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..73f6efd93ecf7bf73c630680f4f40871aff71c10 100644 +index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..575c601452ab89fe411fb848a2391c07d6e13fbd 100644 --- a/src/query.js +++ b/src/query.js @@ -1,3 +1,5 @@ @@ -2332,7 +2343,7 @@ index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..73f6efd93ecf7bf73c630680f4f40871 const originCache = new Map() , originStackCache = new Map() , originError = Symbol('OriginError') -@@ -23,12 +25,17 @@ export class Query extends Promise { +@@ -23,12 +25,16 @@ export class Query extends Promise { this.state = null this.statement = null @@ -2348,11 +2359,10 @@ index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..73f6efd93ecf7bf73c630680f4f40871 this.executed = false + this.trace = undefined + this.poolWait = undefined -+ this.fresh = false this.signature = '' this[originError] = this.handler.debug -@@ -50,7 +57,14 @@ export class Query extends Promise { +@@ -50,7 +56,14 @@ export class Query extends Promise { } cancel() { @@ -2368,7 +2378,7 @@ index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..73f6efd93ecf7bf73c630680f4f40871 } simple() { -@@ -96,8 +110,8 @@ export class Query extends Promise { +@@ -96,8 +109,8 @@ export class Query extends Promise { resolve({ value, done: false }) return new Promise(r => prev = r) } @@ -2379,7 +2389,7 @@ index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..73f6efd93ecf7bf73c630680f4f40871 }) this.execute() return promise -@@ -137,7 +151,15 @@ export class Query extends Promise { +@@ -137,7 +150,15 @@ export class Query extends Promise { } async handle() { @@ -2396,7 +2406,7 @@ index 0d44a15cd9e42beaf11d4be6ccde60223e09508a..73f6efd93ecf7bf73c630680f4f40871 } execute() { -@@ -161,6 +183,11 @@ export class Query extends Promise { +@@ -161,6 +182,11 @@ export class Query extends Promise { } } @@ -2724,10 +2734,10 @@ index 0000000000000000000000000000000000000000..bcdf4c1894e0f84957d7ff3890bea9d8 +} diff --git a/src/tracing.js b/src/tracing.js new file mode 100644 -index 0000000000000000000000000000000000000000..cc9725578b8d5e8a47022f403eccc19b3bc1cfc1 +index 0000000000000000000000000000000000000000..8fb3bc73ed1cbef4347748051e2c051fb3636ed0 --- /dev/null +++ b/src/tracing.js -@@ -0,0 +1,150 @@ +@@ -0,0 +1,186 @@ +import dc from 'diagnostics_channel' +import { performance } from 'perf_hooks' + @@ -2815,29 +2825,57 @@ index 0000000000000000000000000000000000000000..cc9725578b8d5e8a47022f403eccc19b + return { pool: { totalCount, idleCount, waitingCount, maxSize: max } } +} + -+export function poolAcquired(context, processID, reused) { ++export function poolAcquired(context, processID, reused, queuedBehind) { + context.handoverTime = performance.timeOrigin + performance.now() -+ context.queuedBehind = 0 ++ context.queuedBehind = queuedBehind + context.client = { processID, reused } +} + +export function poolHandover(q, queuedBehind, session) { -+ const context = q.poolWait -+ context.handoverTime = performance.timeOrigin + performance.now() -+ context.queuedBehind = queuedBehind -+ context.client = { processID: session.processID, reused: !q.fresh } ++ const wait = q.poolWait ++ wait.context.handoverTime = performance.timeOrigin + performance.now() ++ wait.context.queuedBehind = queuedBehind ++ wait.context.client = { processID: session.processID, reused: !wait.fresh } ++} ++ ++export function traceRoute(q, context, fn) { ++ const wait = q.poolWait = { context, fresh: false, routing: true, activated: false, failure: undefined } ++ try { ++ return run(poolConnectChannel, context, fn, q) ++ } finally { ++ wait.routing = false ++ settleRouted(q, wait) ++ } ++} ++ ++function settleRouted(q, wait) { ++ if (q.poolWait !== wait || !(wait.activated || wait.failure)) ++ return ++ ++ q.poolWait = undefined ++ if (wait.activated) ++ resolved(poolConnectChannel, wait.context) ++ else ++ rejected(poolConnectChannel, wait.context, wait.failure.error) ++ ++ wait.failure && queryFailed(q, wait.failure.error) +} + +export function poolStarted(q) { -+ const context = q.poolWait ++ const wait = q.poolWait ++ if (wait.routing) { ++ wait.activated = true ++ return ++ } ++ + q.poolWait = undefined -+ resolved(poolConnectChannel, context) ++ resolved(poolConnectChannel, wait.context) +} + +function poolFailed(q, error) { -+ const context = q.poolWait ++ const wait = q.poolWait + q.poolWait = undefined -+ rejected(poolConnectChannel, context, error) ++ rejected(poolConnectChannel, wait.context, error) +} + +export function poolReleased(connection, error) { @@ -2873,7 +2911,15 @@ index 0000000000000000000000000000000000000000..cc9725578b8d5e8a47022f403eccc19b +} + +export function queryFailed(q, error) { -+ q.poolWait && poolFailed(q, error) ++ if (q.poolWait) { ++ if (q.poolWait.routing) { ++ q.poolWait.failure = q.poolWait.failure || { error } ++ return ++ } ++ ++ poolFailed(q, error) ++ } ++ + const context = q.trace + q.trace = undefined + rejected(queryChannel, context, error) @@ -3108,7 +3154,7 @@ index 0000000000000000000000000000000000000000..e8a4bddf7c0f4b6c55ec7c5f33865888 + } +} diff --git a/types/index.d.ts b/types/index.d.ts -index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..3c4b90225bb42c28f3a00484bb67a8c4f0dd3f68 100644 +index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..394a76b1d6812d792c70f125acae62a5f4bc502a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,4 +1,4 @@ @@ -3334,11 +3380,11 @@ index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..3c4b90225bb42c28f3a00484bb67a8c4 + waitingCount: number; + maxSize: number; + }; -+ /** Epoch milliseconds at which the connection took the call over; set before `asyncStart` on success. `handoverTime` minus the `start` time is the wait for a connection. */ ++ /** Epoch milliseconds at which the connection took the call over; set before `asyncStart` once a connection took the call over. `handoverTime` minus the `start` time is the wait for a connection. */ + handoverTime?: number; -+ /** Queries the connection already had running or waiting when it took this one over; 0 for a lease. The time from `handoverTime` to `asyncStart` is spent behind them. */ ++ /** Queries the connection already had running or waiting when it took this one over. The time from `handoverTime` to `asyncStart` is spent behind them. */ + queuedBehind?: number; -+ /** Set before `asyncStart` on success */ ++ /** Set with `handoverTime` */ + client?: { + processID: number | undefined; + /** False when the acquisition had to open the connection */ From 7bbe7d77e2b25ea66c1f0eae8a2db89b44dc34fd Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sun, 25 Oct 2026 00:20:35 +0100 Subject: [PATCH 28/34] test(workerd): subscribe no-op handlers to the tracing channels when the driver module asks for it The edge worker reads an optional subscribe export of the generated driver module and subscribes at module scope, with an AsyncLocalStorage bound to each tracing channel's start so runStores does real work. Without the export nothing changes. --- tests-startup/workerd-worker.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests-startup/workerd-worker.js b/tests-startup/workerd-worker.js index a06bf945..d239ba53 100644 --- a/tests-startup/workerd-worker.js +++ b/tests-startup/workerd-worker.js @@ -1,12 +1,28 @@ /* eslint no-console: 0 */ /* global addEventListener */ import * as drivers from './driver.js' +import dc from 'node:diagnostics_channel' +import { AsyncLocalStorage } from 'node:async_hooks' let unhandled = [] addEventListener('unhandledrejection', (event) => { const reason = event.reason?.stack || String(event.reason) unhandled.push(reason) console.error('WORKER_UNHANDLED_REJECTION', reason) }) +if (drivers.subscribe) { + const noop = () => undefined + const storage = new AsyncLocalStorage() + const names = drivers.subscribe === 'query' ? ['query'] : ['query', 'connect', 'pool:connect', 'transaction'] + for (const name of names) { + const channel = dc.tracingChannel('postgres:' + name) + channel.start.bindStore(storage, () => name) + channel.subscribe({ start: noop, end: noop, asyncStart: noop, asyncEnd: noop, error: noop }) + } + if (drivers.subscribe === 'all') { + dc.subscribe('postgres:pool:release', noop) + dc.subscribe('postgres:pool:remove', noop) + } +} const nativeSetTimeout = globalThis.setTimeout globalThis.setTimeout = function(callback, delay, ...extra) { if (extra.length) throw new TypeError('Failed to execute \'setTimeout\': parameter 3 is not of type \'Array\'.') From 2dcc4656f2a2560748d0cc720b15f4598137de35 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sun, 25 Oct 2026 00:20:35 +0100 Subject: [PATCH 29/34] ci: let the Workers A/B job run the head build with subscribers ab_subscribe (none, query, all) makes prepare export the subscription mode to the worker. The base copy's tracing module gets its own channel names, so the no-op subscribers reach only the head build even when both are this branch; with none nothing is rewritten and the job behaves as before. --- .github/workflows/workerd-ab.yml | 9 ++++++++- tests-startup/workerd-edge.mjs | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/workerd-ab.yml b/.github/workflows/workerd-ab.yml index f8455a39..97ee286f 100644 --- a/.github/workflows/workerd-ab.yml +++ b/.github/workflows/workerd-ab.yml @@ -14,6 +14,11 @@ on: ab_base: description: Ref whose cloudflare:sockets build this branch is compared against default: main + ab_subscribe: + description: Channels the head build subscribes to with no-op handlers; the base build never does + type: choice + options: [none, query, all] + default: none permissions: contents: read @@ -24,7 +29,7 @@ concurrency: jobs: ab: - name: Workers CPU A/B against ${{ inputs.ab_base }} on ${{ inputs.ab_target }} + name: Workers CPU A/B against ${{ inputs.ab_base }} on ${{ inputs.ab_target }} (subscribers: ${{ inputs.ab_subscribe }}) runs-on: ubuntu-24.04 timeout-minutes: 25 env: @@ -44,6 +49,8 @@ jobs: - run: npm run build:cf working-directory: ab-base - run: node tests-startup/workerd-edge.mjs prepare "$RUNNER_TEMP/edge" 2026-10-12 "" ab-base + env: + AB_SUBSCRIBE: ${{ inputs.ab_subscribe }} - name: Deploy the Preview and measure env: NEON_DATABASE_URL: ${{ secrets.NEON_DATABASE_URL }} diff --git a/tests-startup/workerd-edge.mjs b/tests-startup/workerd-edge.mjs index 98a40451..b264c19b 100644 --- a/tests-startup/workerd-edge.mjs +++ b/tests-startup/workerd-edge.mjs @@ -24,9 +24,15 @@ const cpuVariants = [ const gate = { median: 1, p95: 1, exceeded: 0.02, p: 0.01, failures: 0.02 } async function prepare(out, compatibilityDate, release, base) { + const subscribe = ['query', 'all'].includes(process.env.AB_SUBSCRIBE) ? process.env.AB_SUBSCRIBE : '' await mkdir(out, { recursive: true }) await cp(path.join(repo, 'cf'), path.join(out, 'cf'), { recursive: true }) base && await cp(path.join(base, 'cf'), path.join(out, 'base/cf'), { recursive: true }) + if (subscribe && base) { + const tracing = path.join(out, 'base/cf/src/tracing.js') + await writeFile(tracing, (await readFile(tracing, 'utf8')).replaceAll('\'postgres:', '\'postgres-base:')) + } + await cp(path.join(repo, 'src'), path.join(out, 'src'), { recursive: true }) await cp(path.join(repo, 'tests-startup/workerd-worker.js'), path.join(out, 'worker.js')) await writeFile(path.join(out, 'driver.js'), [ @@ -35,6 +41,7 @@ async function prepare(out, compatibilityDate, release, base) { 'export { default as src } from \'./src/index.js\'', ...(release ? ['export { default as release } from \'@steadybrew/postgresjs\''] : []), ...(base ? ['export { default as base } from \'./base/cf/src/index.js\''] : []), + ...(subscribe ? ['export const subscribe = \'' + subscribe + '\''] : []), '' ].join('\n')) release && execFileSync('npm', ['install', '--prefix', out, '--no-save', '--no-audit', '--no-fund', release], { stdio: 'inherit' }) From 445a646ac94b990c653e725322301b78ccd2c34e Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sun, 25 Oct 2026 00:21:13 +0100 Subject: [PATCH 30/34] ci: fix the A/B job name, which a colon made invalid YAML --- .github/workflows/workerd-ab.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/workerd-ab.yml b/.github/workflows/workerd-ab.yml index 97ee286f..55457059 100644 --- a/.github/workflows/workerd-ab.yml +++ b/.github/workflows/workerd-ab.yml @@ -29,7 +29,7 @@ concurrency: jobs: ab: - name: Workers CPU A/B against ${{ inputs.ab_base }} on ${{ inputs.ab_target }} (subscribers: ${{ inputs.ab_subscribe }}) + name: Workers CPU A/B against ${{ inputs.ab_base }} on ${{ inputs.ab_target }} with subscribers ${{ inputs.ab_subscribe }} runs-on: ubuntu-24.04 timeout-minutes: 25 env: From bfbbbf701acd064fbaae60b1d309aa506a382916 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sun, 25 Oct 2026 00:30:16 +0100 Subject: [PATCH 31/34] fix: resolve a pool span after its hand-over and flag a lease served by a reconnect A build error for a query routed onto a connection failed the pool span when the query was pipelined behind others but resolved it on an idle or fresh connection. Once a connection took the query over the pool span now resolves whatever the route, so a build error fails only the query span. A queued lease that a closed connection's reconnect served reported reused true. connect() now flags the lease waiter and the acquisition reads the flag. --- src/index.js | 10 +++++----- src/tracing.js | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/index.js b/src/index.js index 271317af..462dfe48 100644 --- a/src/index.js +++ b/src/index.js @@ -289,21 +289,21 @@ function Postgres(a, b) { throw Errors.connection('CONNECTION_ENDED', options) const l = { state: null, connection: null, error: null, outcome: undefined, behind: 0, onclose: null, queue: Queue() } - let reused = true + let waiter = null const idle = takeOpen() idle ? own(idle, l) : auto && !closed.length && busy.length ? own(busy.shift(), l) : await new Promise((resolve, reject) => { - const query = { reserve: c => resolve(own(c, l)), reject } + const query = waiter = { reserve: c => resolve(own(c, l)), reject, fresh: false } closed.length - ? (reused = false, connect(closed.shift(), query)) + ? connect(closed.shift(), query) : allHeldByCopy() ? reject(copyInProgress()) : queries.push(query) }) - trace && poolAcquired(trace, l.connection.session.processID, reused, l.behind) + trace && poolAcquired(trace, l.connection.session.processID, !(waiter && waiter.fresh), l.behind) return l } @@ -642,7 +642,7 @@ function Postgres(a, b) { } function connect(c, query) { - query.poolWait && (query.poolWait.fresh = true) + query.reserve ? query.fresh = true : query.poolWait && (query.poolWait.fresh = true) move(c, connecting) c.connect(query) return c diff --git a/src/tracing.js b/src/tracing.js index 8fb3bc73..42cefb34 100644 --- a/src/tracing.js +++ b/src/tracing.js @@ -113,7 +113,7 @@ function settleRouted(q, wait) { return q.poolWait = undefined - if (wait.activated) + if (wait.activated || wait.context.handoverTime !== undefined) resolved(poolConnectChannel, wait.context) else rejected(poolConnectChannel, wait.context, wait.failure.error) From fdc3c6961801270f867a3917e5ca36cb78ab1a8b Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sun, 25 Oct 2026 00:30:16 +0100 Subject: [PATCH 32/34] test: cover the reconnect path for queries and leases and a lease behind the initial query The busy-connection build failure now expects a resolved pool span. The reconnect tests count the spans reporting reused false: the lease one is 0 without the flag on the waiter. The lease behind the initial query reports queuedBehind 1; it was 0 before leases read the connection's in-flight count. --- tests/tracing.js | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/tracing.js b/tests/tracing.js index fd541940..23d5cd5b 100644 --- a/tests/tracing.js +++ b/tests/tracing.js @@ -741,7 +741,7 @@ t('Tracing a query that fails to build while it is routed settles its pool span at(events, 'pool:connect', 'asyncEnd') < at(events, 'query', 'asyncEnd') ] return [ - JSON.stringify([[plain, failed, 1, 1, true], [failed, failed, 1, 1, true]]), + JSON.stringify([[plain, failed, 1, 1, true], [plain, failed, 1, 1, true]]), JSON.stringify([summary(idle.events), summary(busy.events)]) ] }) @@ -778,3 +778,42 @@ t('Tracing a lease taken on a busy connection reports the queries ahead of it', const [span] = spans(events) return [JSON.stringify([1, true]), JSON.stringify([span.queuedBehind, span.client.reused])] }) + +const reconnected = async(queued) => { + const sql = postgres(options) + const killer = postgres({ ...options, max: 1 }) + const [{ pid }] = await sql`select pg_backend_pid() as pid` + await killer`select 1` + const { events, stop } = collect('query', 'pool:connect') + const running = sql`select pg_sleep(${ 5 })`.execute() + await settle() + const waiting = queued === 'lease' ? sql.reserve() : sql`select 2 as x`.execute() + await killer`select pg_terminate_backend(${ pid })` + const [, outcome] = await Promise.allSettled([running, waiting]) + stop() + outcome.status === 'fulfilled' && queued === 'lease' && outcome.value.release() + await Promise.all([sql.end(), killer.end()]) + return JSON.stringify([outcome.status, spans(events).filter(x => x.client && x.client.reused === false).length]) +} + +t('Tracing a query served by the reconnect of a closed connection reports that it opened the connection', async() => + [JSON.stringify(['fulfilled', 1]), await reconnected('query')] +) + +t('Tracing a lease served by the reconnect of a closed connection reports that it opened the connection', async() => + [JSON.stringify(['fulfilled', 1]), await reconnected('lease')] +) + +t('Tracing a lease served after the initial query of a connection counts that query as ahead of it', async() => { + const sql = postgres(options) + const { events, stop } = collect('pool:connect') + const first = sql`select pg_sleep(0.1)`.execute() + await settle() + const reserved = await sql.reserve() + reserved.release() + stop() + await first + await sql.end() + const [span] = spans(events) + return [JSON.stringify([1, true]), JSON.stringify([span.queuedBehind, span.client.reused])] +}) From b041c17eb00e8645d20a2c6aae56f5b3fac111c0 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sun, 25 Oct 2026 00:30:16 +0100 Subject: [PATCH 33/34] docs: say where a lease's time behind other queries falls and what reused means A lease span ends a microtask after the hand-over, so the time behind the queries ahead of it falls inside its first statement. reused is false only for the call that had to open the connection. --- docs/api.md | 4 ++-- types/index.d.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api.md b/docs/api.md index 910c0f5c..dfb204eb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1592,10 +1592,10 @@ The text is known only after the driver has serialized the query for a connectio | `handoverTime` | before `asyncStart`, once a connection took the call over: the epoch milliseconds at which it did | | `queuedBehind` | with `handoverTime`: how many queries the connection already had running or waiting when it took the call over | | `client.processID` | with `handoverTime` | -| `client.reused` | with `handoverTime`: false when the wait included opening the connection | +| `client.reused` | with `handoverTime`: false when this call had to open the connection; a call that waited while another call opened it reports true | | `error` | before `error`: a connection error, `CONNECTION_ENDED` after `sql.end()`, `COPY_IN_PROGRESS`, or the cancellation or connection error of a plain query that settled before it started | -`handoverTime` minus the time of `start` is the wait for a connection, which is what OpenTelemetry's `db.client.connection.wait_time` measures. When `queuedBehind` is above 0, the time from `handoverTime` to `asyncStart` was spent behind those queries on the same connection. Take the `start` time yourself in the `start` subscriber. On Cloudflare Workers `performance.now()` advances only after I/O, so within a request both times can be equal. +`handoverTime` minus the time of `start` is the wait for a connection, which is what OpenTelemetry's `db.client.connection.wait_time` measures. For a plain query with `queuedBehind` above 0, the time from `handoverTime` to `asyncStart` was spent behind those queries on the same connection. A lease span ends a microtask after the hand-over, so the time behind those queries falls inside the lease's first statement. Take the `start` time yourself in the `start` subscriber. On Cloudflare Workers `performance.now()` advances only after I/O, so within a request both times can be equal. ```js import dc from 'node:diagnostics_channel' diff --git a/types/index.d.ts b/types/index.d.ts index 394a76b1..09ebd712 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -840,7 +840,7 @@ declare namespace postgres { }; /** Epoch milliseconds at which the connection took the call over; set before `asyncStart` once a connection took the call over. `handoverTime` minus the `start` time is the wait for a connection. */ handoverTime?: number; - /** Queries the connection already had running or waiting when it took this one over. The time from `handoverTime` to `asyncStart` is spent behind them. */ + /** Queries the connection already had running or waiting when it took this one over. For a plain query, the time from `handoverTime` to `asyncStart` is spent behind them; a lease span ends a microtask after the hand-over, so that time falls inside its first statement. */ queuedBehind?: number; /** Set with `handoverTime` */ client?: { From 0be0649f935d7fc76a66c9d0bd1a543d4615035f Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Sun, 25 Oct 2026 00:30:16 +0100 Subject: [PATCH 34/34] docs: refresh the diff since 3.4.9 --- docs/changes-since-3.4.9.diff | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/changes-since-3.4.9.diff b/docs/changes-since-3.4.9.diff index 4bd7e5d2..22453f1c 100644 --- a/docs/changes-since-3.4.9.diff +++ b/docs/changes-since-3.4.9.diff @@ -1539,7 +1539,7 @@ index 0ff83c428bec774e11417b8e1f033dc3305c18cf..023c3ba1bd113e6f5508ab079efcf220 code: x, errno: x, diff --git a/src/index.js b/src/index.js -index c7fba3dace20173f4cb13f3f138bb6d213b43683..271317afcfa47c1d5557a0ca96bce9651f4467e9 100644 +index c7fba3dace20173f4cb13f3f138bb6d213b43683..462dfe482408620ba51d557cb5cd1d8b98949438 100644 --- a/src/index.js +++ b/src/index.js @@ -3,10 +3,12 @@ import fs from 'fs' @@ -1763,21 +1763,21 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..271317afcfa47c1d5557a0ca96bce965 + throw Errors.connection('CONNECTION_ENDED', options) + + const l = { state: null, connection: null, error: null, outcome: undefined, behind: 0, onclose: null, queue: Queue() } -+ let reused = true ++ let waiter = null + const idle = takeOpen() + idle + ? own(idle, l) + : auto && !closed.length && busy.length + ? own(busy.shift(), l) + : await new Promise((resolve, reject) => { -+ const query = { reserve: c => resolve(own(c, l)), reject } ++ const query = waiter = { reserve: c => resolve(own(c, l)), reject, fresh: false } + closed.length -+ ? (reused = false, connect(closed.shift(), query)) ++ ? connect(closed.shift(), query) + : allHeldByCopy() + ? reject(copyInProgress()) + : queries.push(query) + }) -+ trace && poolAcquired(trace, l.connection.session.processID, reused, l.behind) ++ trace && poolAcquired(trace, l.connection.session.processID, !(waiter && waiter.fresh), l.behind) + return l + } + @@ -2168,7 +2168,7 @@ index c7fba3dace20173f4cb13f3f138bb6d213b43683..271317afcfa47c1d5557a0ca96bce965 } function connect(c, query) { -+ query.poolWait && (query.poolWait.fresh = true) ++ query.reserve ? query.fresh = true : query.poolWait && (query.poolWait.fresh = true) move(c, connecting) c.connect(query) return c @@ -2734,7 +2734,7 @@ index 0000000000000000000000000000000000000000..bcdf4c1894e0f84957d7ff3890bea9d8 +} diff --git a/src/tracing.js b/src/tracing.js new file mode 100644 -index 0000000000000000000000000000000000000000..8fb3bc73ed1cbef4347748051e2c051fb3636ed0 +index 0000000000000000000000000000000000000000..42cefb340333894c94f101919e479387a169ebe0 --- /dev/null +++ b/src/tracing.js @@ -0,0 +1,186 @@ @@ -2853,7 +2853,7 @@ index 0000000000000000000000000000000000000000..8fb3bc73ed1cbef4347748051e2c051f + return + + q.poolWait = undefined -+ if (wait.activated) ++ if (wait.activated || wait.context.handoverTime !== undefined) + resolved(poolConnectChannel, wait.context) + else + rejected(poolConnectChannel, wait.context, wait.failure.error) @@ -3154,7 +3154,7 @@ index 0000000000000000000000000000000000000000..e8a4bddf7c0f4b6c55ec7c5f33865888 + } +} diff --git a/types/index.d.ts b/types/index.d.ts -index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..394a76b1d6812d792c70f125acae62a5f4bc502a 100644 +index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..09ebd712094980b56257a6095a80510adec52af5 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,4 +1,4 @@ @@ -3382,7 +3382,7 @@ index 13c3432f761e2c2ee5b7dc1bdafe93e49f993dc7..394a76b1d6812d792c70f125acae62a5 + }; + /** Epoch milliseconds at which the connection took the call over; set before `asyncStart` once a connection took the call over. `handoverTime` minus the `start` time is the wait for a connection. */ + handoverTime?: number; -+ /** Queries the connection already had running or waiting when it took this one over. The time from `handoverTime` to `asyncStart` is spent behind them. */ ++ /** Queries the connection already had running or waiting when it took this one over. For a plain query, the time from `handoverTime` to `asyncStart` is spent behind them; a lease span ends a microtask after the hand-over, so that time falls inside its first statement. */ + queuedBehind?: number; + /** Set with `handoverTime` */ + client?: {