Pgbench: remove synchronous prepare

First seen: 2026-01-27 03:34:51+00:00 · Messages: 10 · Participants: 3

Latest Update

2026-05-06 · opus 4.7

Pgbench: Remove Synchronous Prepare — Technical Analysis

The Core Problem

pgbench with --protocol=prepared has a latent bug that surfaces when used behind a connection pooler (e.g., PgBouncer in session mode) configured with fewer backends than pgbench clients. Under those conditions pgbench appears to "freeze."

The root cause is a design asymmetry in pgbench's client state machine:

The synchronous blocking is invisible when one client == one backend, because the server responds immediately. But with a session-mode pooler and clients > pool_size, some pgbench clients are parked in the pooler's wait queue with no backend assigned. PQprepare() for such a client will never return. Because a single pgbench thread multiplexes many clients via select()/poll(), that thread is now wedged inside PQexecFinish() and cannot service the other clients that do have backends and are receiving responses. The whole thread deadlocks — not a protocol deadlock, but a scheduling one induced by mixing a blocking call into an otherwise non-blocking event loop.

This is architecturally important beyond the pgbench-plus-pooler case: any latency spike on the first prepare of a given statement (network blip, slow parse planning, etc.) will stall all clients sharing that pgbench thread. The synchronous prepare was always a correctness smell; the pooler scenario just makes it visible.

The Proposed Fix

Bondar's patch removes the synchronous PQprepare() call from pgbench's first-use path and replaces it with a new libpq entrypoint that sends the full Parse/Bind/Execute/Sync sequence in one asynchronous dispatch — essentially converting the prepared-protocol first-execution path to behave like the extended-protocol path, except the statement is given a real (reusable) name rather than being unnamed.

The new libpq function (initially named PQsendPBES) is structurally a near-duplicate of PQsendQueryPrepared but prepends a Parse message. Subsequent executions of the same statement continue to go through the existing PQsendQueryPrepared, which already was asynchronous — so steady-state behavior is unchanged. Only the cold-path "first time we see this statement on this connection" is altered.

Key Design Decisions and Tradeoffs

Why not just use the existing PQsendPrepare?

This is Robert Haas's natural first question, and Bondar's answer exposes the real constraint. pgbench's result-processing state machine is built to consume a complete query result (tuples for \gset, row counts, etc.). A standalone ParseComplete reply carries no tuple data, so meta-commands like \gset break with expected one row, got 0.

The alternative — send Parse via PQsendPrepare and then send Bind/Execute/Sync via PQsendQueryPrepared without waiting — is blocked by a libpq invariant: PQsendQueryStart refuses to queue a second command on a connection unless pipeline mode is explicitly enabled. So the choices collapse to:

  1. Relax the "one command at a time" rule in libpq (broad, risky).
  2. Add a new pgbench state-machine state to handle the ParseComplete reply separately (invasive to pgbench, and arguably the wrong modeling — the two messages logically belong together).
  3. Introduce a single libpq function that emits Parse+Bind+Execute+Sync atomically and reports completion once all replies are drained (the chosen path).

Option 3 localizes the change and preserves both libpq's single-command-per-dispatch invariant and pgbench's "one result per command" assumption. The cost is a libpq entrypoint that is effectively pgbench-specific today.

The test expectation change

The patch adjusts 001_pgbench_with_server.pl. Under the old path, a prepareCommand with a syntax error would fail at Parse time but, because prepareCommand doesn't propagate status, pgbench would press on and issue Bind, producing a second, redundant prepared statement ... does not exist error. Under the new path, Parse/Bind/Execute/Sync are pipelined; when Parse errors, the server discards messages up to the next Sync, so only the syntax error appears. This is a user-visible improvement (cleaner error reporting) but one the original submission didn't call out — Haas flagged it as evidence that more than "async vs sync" was changing.

Naming

Haas pushes back on PQsendPBES — libpq's public API deliberately abstracts over wire-protocol message names, and introducing a function whose name encodes protocol message letters would be the sole exception. Bondar's later suggestions (PQsendQueryPrepare, PQsendPrepareQuery, PQsendPrepareExecute) are all weak: the first collides visually with PQsendQueryPrepared, the others don't clearly communicate that the statement is named and retained for future reuse. Naming remains unresolved at thread end and is the main blocker for committer acceptance.

Why This Matters Beyond pgbench

Although the patch is presented as a pgbench bugfix, it exposes a genuine gap in libpq's async API: there is no single-call way to prepare-and-execute a named statement without either (a) using pipeline mode, or (b) making two synchronous round trips. Anyone writing an async libpq client that wants to amortize Parse across repeated executions of a named statement hits exactly this wall. A general-purpose PQsendPrepareExecute (or similar) would be useful infrastructure, not just pgbench plumbing — which is probably the strongest argument for accepting it as a first-class libpq function rather than hiding it in pgbench.

Open Issues at Thread End

  1. Naming of the new libpq function — Haas has rejected PQsendPBES and none of Bondar's alternatives are obviously right.
  2. Scope justification — whether to frame the function as pgbench-internal or as a genuine libpq addition with its own documentation and stability guarantees.
  3. Behavioral change to error output in the test — should be documented in the commit message.
  4. No committer has yet signed off; Lakshmi G's review is a user-level "works for me," not a code-level review from someone with commit authority.