Skip to content

Fix cancelling a query that has not started - #1256

Open
GrahamCampbell wants to merge 1 commit into
porsager:masterfrom
GrahamCampbell:cancel-pipelined-query
Open

GrahamCampbell wants to merge 1 commit into
porsager:masterfrom
GrahamCampbell:cancel-pipelined-query

Conversation

@GrahamCampbell

@GrahamCampbell GrahamCampbell commented Oct 12, 2026 •

Copy link
Copy Markdown

A query cancelled while it waits piped behind another is skipped in ReadyForQuery, but the server still answers it, so the query behind it takes that answer, every later answer on the connection lands one query late, and the cancelled query never settles (#1252). A query cancelled while its connection is still opening leaves that connection in connecting with nothing in flight, so the pool hands it nothing until max_lifetime ends it.

This changes two lines in ReadyForQuery. The while becomes an if, so a cancelled query stays current and settles with its own answer. The return ending the initial branch is removed, so a connection whose first query did not start falls through to onopen; the same fall-through lets a reserve() that opens a connection with fetch_types: false resolve (#1219).

Fixes #1252

Mechanism, reproduction and output

A query that has been piped to a busy connection but has not started yet can be cancelled, and cancel() then only records the request (query.cancelled = { resolve, reject }). When the query ahead of it finishes, the loop in ReadyForQuery shifts the cancelled query off sent, sends its CancelRequest and, because the query was cancelled, goes round again and makes the next query current. The server still answers the cancelled query, since it already has its messages, so the next query receives that answer: the cancelled query's 57014, or its rows if it finished before the CancelRequest arrived. Every later answer on the connection then lands one query late, and the cancelled query is in neither query nor sent, so nothing settles it.

A query cancelled while its connection is still opening has a related problem in the same function. It is the connection's initial query and has no backend state yet, so cancel() rejects it at once and marks it cancelled. When the connection is ready, the initial branch calls execute(initial), which returns at once for a cancelled query, and then returns without reaching onopen, so the connection stays in connecting with nothing in flight. The pool does not hand it anything again until max_lifetime ends it, and with max: 1 every query waits that long.

This changes two lines in ReadyForQuery. The while becomes an if, so a cancelled query stays current with its CancelRequest sent and settles with its own answer at its own ReadyForQuery, where the next query is shifted the same way; two cancelled queries in a row are each handled in turn. The return at the end of the initial branch is removed, so a connection whose initial query did not start falls through to the common tail and does what any ready connection does, onopen, or terminate() when the pool is ending. When execute(initial) did start the query, if (query) return returns as before, and sent is empty on a connection that has just opened. The same fall-through lets a reserve() that opens a connection with fetch_types: false resolve (#1219, which #1220 addresses in the same lines). The CancelRequest still goes out when the cancelled query becomes current, so the race the README describes, where a CancelRequest that arrives after its query has finished can cancel a later query on that backend, is unchanged.

Run against a plain PostgreSQL 17 server, with the connection taken from the PG* environment variables:

import net from 'node:net'
import postgres from 'postgres'

const settled = (name, p) => p.then(
  x => console.log(name, 'resolved', JSON.stringify(x)),
  e => console.log(name, 'rejected', e.code)
)

console.log('1. A query cancelled while it waits piped behind another, with a query behind it')
{
  const sql = postgres({ max: 1 })
  await sql`select 1`
  sql`select pg_sleep(0.5)`.execute()
  const query = sql`select pg_sleep(2) as cancelled`
  settled('  cancelled:', query)
  settled('  next     :', sql`select 1 as x`)
  setTimeout(() => query.cancel(), 100)
  await new Promise(r => setTimeout(r, 3000))
  await sql.end({ timeout: 0 })
}

console.log('2. A query cancelled while its connection opens, with a query behind it')
{
  let open
  const opened = new Promise(r => open = r)
  const sql = postgres({ max: 1, socket: async o => (await opened, net.connect(o.port[0], o.host[0])) })
  const query = sql`select 1 as cancelled`
  settled('  cancelled:', query)
  await new Promise(r => setTimeout(r, 10))
  query.cancel()
  settled('  next     :', sql`select 2 as x`)
  open()
  await new Promise(r => setTimeout(r, 3000))
  await sql.end({ timeout: 0 })
}

On master (411429e):

1. A query cancelled while it waits piped behind another, with a query behind it
  next     : rejected 57014
2. A query cancelled while its connection opens, with a query behind it
  cancelled: rejected 57014
  next     : rejected CONNECTION_DESTROYED

The cancelled query in the first case never settles, and the next query in the second is only rejected when sql.end({ timeout: 0 }) destroys the pool three seconds later. With this change:

1. A query cancelled while it waits piped behind another, with a query behind it
  cancelled: rejected 57014
  next     : resolved [{"x":1}]
2. A query cancelled while its connection opens, with a query behind it
  cancelled: rejected 57014
  next     : resolved [{"x":2}]

The two new tests, "Cancel piped query with a query piped behind it" and "Cancel query while its connection is opening", fail on master and pass with the change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cancelling a pipelined query that is not yet active leaves the next query with the wrong result

1 participant