Skip to content

Run onexecute regardless of max_pipeline so begin() works at 0 - #1211

Open
reinierlakhan wants to merge 1 commit into
porsager:masterfrom
reinierlakhan:fix/begin-with-max-pipeline-0
Open

Run onexecute regardless of max_pipeline so begin() works at 0#1211
reinierlakhan wants to merge 1 commit into
porsager:masterfrom
reinierlakhan:fix/begin-with-max-pipeline-0

Conversation

@reinierlakhan

Copy link
Copy Markdown

Fixes #1210

Cause

execute(q) in src/connection.js returns one && chain that does two unrelated jobs: it decides whether the pool may pipeline another query onto this connection, and it runs the query's onexecute hook as its last term:

      return write(toBuffer(q))
        && !q.describeFirst
        && !q.cursorFn
        && sent.length < max_pipeline
        && (!q.options.onexecute || q.options.onexecute(connection))

begin() sends BEGIN with { onexecute } and relies on that hook to capture the connection, move(c, reserved) and set c.reserved. With max_pipeline: 0 the sent.length < max_pipeline term is always false, so the chain short-circuits before the hook. The connection is never reserved, and the UNSAFE_TRANSACTION guard in CommandComplete rejects every transaction.

Change

Run the hook whenever the query was actually written, independent of pipeline capacity, and keep the return value's meaning ("may the pool pipeline more onto this connection") unchanged:

      const written = write(toBuffer(q))
      written && q.options.onexecute && q.options.onexecute(connection)
      return written
        && !q.describeFirst
        && !q.cursorFn
        && sent.length < max_pipeline

max_pipeline > 0: behaviour is identical. onexecute in begin() (the only caller) returns a truthy value (the assigned c.reserved function), so the old chain was truthy exactly when the new one is, and the hook ran in exactly the cases it runs now (previously it also only ran when write() returned truthy, which written && preserves).

max_pipeline: 0: execute() reserves the connection and returns false, so go() moves it to full. c.reserved is set, so the BEGIN passes the guard. On the BEGIN's ReadyForQuery (status T), connection.reserved() runs, finds the transaction's queue empty and moves the connection back to reserved. The transaction callback only starts after that (it is awaited behind the BEGIN), and each statement it issues goes through begin()'s handler: execute() returns false -> move(c, full); anything issued while in full is pushed onto the transaction's own queries and drained one at a time by c.reserved() on each ReadyForQuery. Ordering is preserved and nothing is pipelined onto the socket, which is the point of the setting.

Tests

Three tests added to tests/index.js, each on a client with { max: 4, max_pipeline: 0 }:

  • a single-statement sql.begin(sql => sql\select 1 as x`)returns1`
  • two sequential statements in one transaction (set_config(..., true) then current_setting, so it also proves they ran on the same transaction)
  • two concurrent statements in one transaction (array form), which exercises the queries queue path

Run in isolation against a plain PostgreSQL 16 through the repo's tests/test.js harness: all three pass with this change; without it the first fails with UNSAFE_TRANSACTION. eslint src tests with the repo config is clean.

The full suite did not run here: tests/bootstrap.js needs psql/createdb/dropdb on PATH and a server it may reconfigure (ssl=on, wal_level=logical, prepared transactions), which this environment does not have. CI should cover it.

I left cjs/, deno/ and cf/ untouched, following the pattern of separate build commits in this repo; happy to regenerate them in this PR if preferred.

execute() returned a single && chain that did two unrelated jobs:
deciding whether the pool may pipeline another query onto this
connection, and running the query's onexecute hook. begin() relies on
that hook to capture the connection and move it to the reserved queue.

With max_pipeline: 0 the `sent.length < max_pipeline` term is always
false, so the chain short-circuited before onexecute ran. The connection
was never reserved and the BEGIN's CommandComplete guard rejected every
transaction with UNSAFE_TRANSACTION.

Run the hook whenever the query was actually written, and keep the
return value's meaning ("may the pool pipeline more onto this
connection") unchanged. For max_pipeline > 0 behaviour is identical:
onexecute returns a truthy value, so the old chain was truthy exactly
when the new one is. For max_pipeline: 0 execute() now returns false
after reserving, the pool moves the connection to full, and on the
BEGIN's ReadyForQuery connection.reserved() drains the transaction's own
queue or moves it back to reserved, so nothing is pipelined.
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.

sql.begin() always throws UNSAFE_TRANSACTION when max_pipeline is 0

1 participant