droid
Transaction Pooling
Pooling · Stage 10 of 11

Transaction Pooling

Lend a server connection for one transaction at a time, learn from the status byte when it ends, and let many clients share a few connections

In --mode transaction, give a client a server connection when it sends a message and holds none, and take it back the moment the server's ReadyForQuery says I, with no reset; keep it lent while a transaction is open, failed or not; serve waiting clients in order; close a client that waits past --query-wait-timeout-ms with 08P01; and know what this costs: session state stays with the connection, not the client.

Run the tests on your machine

This part needs a network, which the platform's test sandbox does not have, so its tests run on your computer. Download the test kit (it needs only Python 3) and your project, build your program, and run:

python3 run.py --stage pool-transaction --bin ./c-db
Download the test kit

Sign in to download the code you wrote here.

Concepts in this stage
  • Most clients are idle between transactions, so a connection only needs to be lent for one
  • The status byte is the pooler's clock: I means the transaction is over and the connection is free
  • A transaction never moves: T and E keep the connection with its client
  • Session state belongs to the server connection, not to the client: prepared statements and settings stay behind
  • A bounded wait: a client queued too long is better told than left hanging
Where this stage sits in the engine
Talking to the user
REPLSessionsPrinter
Server
Wire protocol
Understanding SQL
LexerParserValidator
Answering a query
PlannerExecutor
Keeping changes safe
TransactionsWrite-ahead logUndo logLock managerRead views
Storing rows
Row formatSlotted pageB+Tree
Reaching the disk
PagerDatabase file
this stagebuilt alreadystill to comeA statement enters at the top; rows come back up.

Why this stage exists

Session pooling saves little when clients stay connected. A web worker keeps its connection for hours and uses it a few milliseconds per request. The real unit of work is the transaction: between two of them, the connection does nothing. Transaction mode lends a server connection for one transaction and takes it back the instant that transaction ends. Ten clients that each spend one percent of their time in a transaction can then share a single connection.

The pooler doesn't parse SQL to find where a transaction ends. The server already says so, after every answer, in ReadyForQuery's status byte (section 96):

Drawing the diagram…

An autocommit statement is a transaction of its own: its answer ends with I, so it holds a connection only while it runs.

A transaction never moves

A connection is lent for a whole transaction, and a transaction never moves to another connection halfway. Its locks, its undo records and its read view all live in the server session that began it. So T keeps the connection with its client. So does E: a failed transaction still has to be rolled back on the session that holds it.

What it costs

The convenience has a price, and both of this stage's last two cases pin it down. A server connection carries session state: the statements prepared on it and the session's isolation level. In session mode that state belonged to one client and was reset when it left. In transaction mode, consecutive transactions of one client may run on different connections, and one connection serves many clients in turn:

  • a client prepares p on server 1, and its next transaction runs on server 2: p is unknown there (26000);
  • a client sets READ COMMITTED on server 1 and gives it back, and the next client to get server 1 is now at READ COMMITTED.

PgBouncer documents the same list of features that break in transaction mode. The next stage fixes the first, for the prepared statements drivers make with Parse.

The algorithm in words

1

Lending per transaction

Section 101's lending, with a different moment of return.

  1. 1.When a client with no connection sends a message, lend it one as before: idle, new, or wait in order.
  2. 2.Forward as before, and count the Queries and Syncs you forward on the connection: each is owed one ReadyForQuery. A client may send several before the first answer comes back.
  3. 3.Each time you forward a ReadyForQuery from the server to the client, count one answer received, and look at its status byte.
  4. 4.I, and no answer still owed: the transaction is over. Log client <c> released server <s> and lend the connection to the first waiting client. Do not reset it: the next transaction starts from the state the last one left. I with answers still owed: keep it, the client has more on the way.
  5. 5.T or E: a transaction is open, or failed. The connection stays with the client until a later ReadyForQuery says I.
  6. 6.A client that leaves while it holds a connection releases it with the reset of section 101, after reading and dropping every answer the server still owes it, so that none is taken for an answer to the reset.
2

Waiting too long

A queue with a deadline.

  1. 1.Remember when each waiting client began to wait.
  2. 2.Make the loop wake for the earliest deadline. A client that has waited --query-wait-timeout-ms gets FATAL 08P01 and is closed, and leaves the queue.

Output contract for this stage

These are the exact lines and messages the tests check. The full definition is on the output contract page, section 102.

Eventdroid-pool
a ReadyForQuery I forwarded, in transaction modelogs [POOL] client <c> released server <s>; the connection is idle, not reset
a ReadyForQuery T or E forwardedthe connection stays lent
a client waits past --query-wait-timeout-mssends it ErrorResponse FATAL 01923 (08P01), then closes it

What you should see when done

shell
$droid-pool --port 6432 --server-port 5433 --pool-size 1 --mode transaction
[POOL] listening on 127.0.0.1:6432
[POOL] client 1 connected
[POOL] client 2 connected
[POOL] server 1 connected
[POOL] client 1 uses server 1
[POOL] client 2 waits
[POOL] client 1 released server 1
[POOL] client 2 uses server 1
[POOL] client 2 released server 1

Common bugs in this stage

  • Releasing at COMMIT in the SQL. A COMMIT can fail, and a transaction can end by a ROLLBACK or by an error in an implicit transaction. Release on the status byte, never on the SQL.
  • Releasing on E. The failed transaction still has to be rolled back where it lives.
  • Resetting at every release. That would wipe what the client meant to keep, and it costs a round trip per transaction. Transaction mode resets nothing; only a client that leaves gets the reset.
  • Releasing in the middle of a batch, or of a pipeline. The extended protocol sends its ReadyForQuery only at Sync, but a client may pipeline several Queries or Syncs: release only when every one you forwarded has had its ReadyForQuery.

Language notes

The page above is the same for every language. These are the only language-specific hints.

  • C: the forwarding loop already sees each whole message; check type == 'Z' and the byte after the length.
  • C++: the same, on your message struct.
  • Rust: a match on the message type in the server-to-client direction, with the release as one arm.
  • Zig: a switch on the type byte.

Checklist

One line per thing the tests check. The tags name the test cases.

  • A connection goes back to the pool when its transaction ends, while its client stays connected
    released-at-commit
  • A connection stays with its client while a transaction is open
    held-during-transaction
  • Single statements from many clients share one connection
    autocommit-statements-interleave
  • Waiting clients are served in the order they came
    fifo-when-exhausted
  • A client that waits too long gets FATAL 08P01 and is closed
    wait-timeout
  • A failed transaction keeps its connection until it is rolled back
    failed-transaction-still-held
  • A statement prepared on one connection is missing when the next transaction runs on another
    prepared-statement-lost
  • A session setting stays on the connection for the next client
    isolation-level-leaks