From 6094b903ca7bd343549d4ab28a10006c37bd2aa2 Mon Sep 17 00:00:00 2001 From: maxymlyskov Date: Tue, 29 Sep 2026 20:10:28 +0300 Subject: [PATCH] fix(pg): keep a named statement usable after its values fail to serialize When a value's toPostgres() throws, or JSON.stringify meets a circular object, the catch around bind() sends a Close for the statement and a Sync. For a named statement the Close removes it from the server, but the client still lists it in parsedStatements: on first use the ParseComplete for the Parse already sent arrives after the catch and records the name, and on a later use the entry is already there. Every later query with that name skips Parse and fails with 26000 prepared statement "" does not exist for the life of the connection, and so does a query with the same name pipelined behind the failing one. Only close the unnamed statement. A named statement that parsed stays prepared and matches the client's cache, so the next run binds to it as it would after any other error. --- packages/pg/lib/query.js | 6 +- .../client/prepared-statement-tests.js | 57 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/pg/lib/query.js b/packages/pg/lib/query.js index 3b93cb5ba..f00b74342 100644 --- a/packages/pg/lib/query.js +++ b/packages/pg/lib/query.js @@ -235,8 +235,10 @@ class Query extends EventEmitter { valueMapper: utils.prepareValue, }) } catch (err) { - // we should close parse to avoid leaking connections - connection.close({ type: 'S', name: this.name }) + // only close the unnamed statement. a named one stays prepared and is used again + if (!this.name) { + connection.close({ type: 'S', name: this.name }) + } connection.sync() this.handleError(err, connection) diff --git a/packages/pg/test/integration/client/prepared-statement-tests.js b/packages/pg/test/integration/client/prepared-statement-tests.js index 5c102eb13..7bea1c19a 100644 --- a/packages/pg/test/integration/client/prepared-statement-tests.js +++ b/packages/pg/test/integration/client/prepared-statement-tests.js @@ -228,3 +228,60 @@ const suite = new helper.Suite() suite.test('cleanup', () => client.end()) })() +;(function () { + const name = 'bind-throw' + const text = 'SELECT $1::text AS v' + const badValue = { + toPostgres() { + throw new Error('bad value') + }, + } + + suite.test( + 'a named statement still runs after its values fail to serialize', + !helper.args.native && + async function () { + const client = helper.client() + try { + await assert.rejects(client.query({ name, text, values: [badValue] }), /bad value/) + const result = await client.query({ name, text, values: ['ok'] }) + assert.equal(result.rows[0].v, 'ok') + } finally { + await client.end() + } + } + ) + + suite.test( + 'an already prepared statement still runs after its values fail to serialize', + !helper.args.native && + async function () { + const client = helper.client() + try { + await client.query({ name, text, values: ['first'] }) + await assert.rejects(client.query({ name, text, values: [badValue] }), /bad value/) + const result = await client.query({ name, text, values: ['ok'] }) + assert.equal(result.rows[0].v, 'ok') + } finally { + await client.end() + } + } + ) + + suite.test( + 'a pipelined query with the same name still runs after the first fails to serialize', + !helper.args.native && + async function () { + const client = helper.client(undefined, { pipeline: true }) + try { + const failed = client.query({ name, text, values: [badValue] }) + const next = client.query({ name, text, values: ['ok'] }) + await assert.rejects(failed, /bad value/) + const result = await next + assert.equal(result.rows[0].v, 'ok') + } finally { + await client.end() + } + } + ) +})()