droid
A Pooling Proxy
Pooling · Stage 9 of 11

A Pooling Proxy

A second program between clients and the server: many clients, a few server connections, and each one reset before it is lent again

Write droid-pool: listen for clients and greet them yourself; open at most --pool-size connections to the server, lazily; in session mode lend one to a client at its first message until it leaves, forwarding every message unchanged both ways; queue clients when none is free; reset a connection before lending it again; log every lend and release; answer SHOW POOLS on the admin connection; and survive the server going away.

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-session --bin ./c-db
Download the test kit

Sign in to download the code you wrote here.

Concepts in this stage
  • A connection to a database is expensive; a client that is idle most of the time does not need its own
  • A proxy speaks the protocol on both sides, so the encoder and decoder you built are reused whole
  • Lending: a client is given a server connection, holds it, and gives it back
  • A connection given back must be reset: its last client's transaction and prepared statements are not the next one's
  • Back-pressure: when every connection is lent, a client waits, in order
  • An admin connection that describes the pool, as PgBouncer's SHOW POOLS does
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

Each connection to a database costs the server something: memory for its session, a slot under --max-connections, and in PostgreSQL a whole operating-system process. A web application with fifty workers, each holding a connection that is idle 99% of the time, wastes almost all of it. A connection pooler sits in the middle. Clients connect to it as if it were the database. It keeps a few connections to the real server and lends them out.

Drawing the diagram…

droid-pool is a second program, in the spirit of PgBouncer, and building it is the payoff for the last stages. It speaks the protocol on both sides, so it is built almost entirely from code you already have: the poll loop, the buffers, the reader that cuts bytes into messages, the writer that builds them. What is new is the bookkeeping, which client holds which connection.

Session mode: lend for as long as the client stays

The simplest pooling is session mode. A client is given a server connection when it first needs one, and keeps it until it leaves. Everything between the two is forwarded unchanged, in both directions, message by message. The pooler does not need to understand the messages, only to find where each one ends.

The pool has a size. When every connection is lent and another client needs one, that client waits, in order. Its query simply gets no answer yet, as a statement waiting for a lock gets none. When someone leaves, the first waiting client is given their connection.

Reset before you lend again

A connection carries its last client's state. Maybe a transaction was left open by a client that crashed. Maybe there are prepared statements named p, or an isolation level that was changed. If the pool lends that connection unchanged, the next client inherits all of it: it is suddenly inside someone else's transaction and sees their uncommitted rows. So before lending a connection again, the pool resets it. It sends ROLLBACK if the connection's last ReadyForQuery said T or E, then DEALLOCATE ALL, then the default isolation level, each as a Query of its own, and reads the answers itself. They must be separate Queries: sent together, they would run as one implicit transaction (section 96), and SET TRANSACTION is refused inside a transaction. The next client finds a connection as good as new.

Notice how the pool knows whether a transaction is open: from the status byte of the last ReadyForQuery the server sent on that connection. Section 96 made the server report it after every answer. The pool doesn't have to parse SQL to know where a transaction begins and ends.

The admin connection

How does anyone see what the pool is doing? PgBouncer answers queries on a virtual database named pgbouncer. droid-pool does the same with pool: a client whose StartupMessage names that database gets no server connection, and SHOW POOLS returns one row of counts.

shell
$psql -h 127.0.0.1 -p 6432 -U droid pool
pool=> show pools;
cl_active | cl_waiting | sv_active | sv_idle
-----------+------------+-----------+---------
2 | 1 | 2 | 0
(1 row)
 

The algorithm in words

1

Starting and greeting

droid-pool is its own program, with its own poll loop.

  1. 1.Read the flags; anything that cannot be understood is 01920. Listen on 127.0.0.1 and the port, and print [POOL] listening on 127.0.0.1:<port>.
  2. 2.Accept clients, number them from 1, log [POOL] client <c> connected, and answer their startup yourself: N for an encryption request, then the greeting of section 94, with the client's number as process id. Open no server connection for this.
  3. 3.A StartupMessage naming the database pool makes the admin connection.
2

Lending and forwarding

In session mode a client holds its connection from its first message until it leaves.

  1. 1.When a client sends its first message after the greeting, give it an idle server connection. If none is idle and fewer than --pool-size are open, open one: connect to --server-port, send a StartupMessage for user droid and database droid, read the greeting, log server <s> connected. Otherwise log client <c> waits and queue the client.
  2. 2.Log client <c> uses server <s>. From now on forward every whole message from the client to its server connection, and every whole message from the server connection back to the client, unchanged.
  3. 3.Remember the status byte of the last ReadyForQuery each server connection sent.
3

Giving back

The next client must find the connection as if it were new.

  1. 1.When a client leaves: if its connection's last status was T or E, send it ROLLBACK and read to ReadyForQuery. Then send DEALLOCATE ALL, and then SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, as two Queries, each read to ReadyForQuery. Forward none of this.
  2. 2.Log client <c> released server <s>, and lend the connection to the first waiting client, if any.
  3. 3.If a server connection fails or closes, log server <s> closed, send its client FATAL 08006 and close it. A client that needs a connection the pool cannot open gets the same.

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 101.

Eventdroid-pool's stdout
ready[POOL] listening on 127.0.0.1:<port>
a client accepted / gone[POOL] client <c> connected, [POOL] client <c> closed
a client must wait[POOL] client <c> waits
a client is given a connection[POOL] client <c> uses server <s>
a connection is back, after its reset[POOL] client <c> released server <s>
a server connection opened / lost[POOL] server <s> connected, [POOL] server <s> closed
Admin QueryAnswer
SHOW POOLSRowDescription cl_active, cl_waiting, sv_active, sv_idle (all int4), one DataRow, SHOW
anything else01921 (0A000)

The admin connection is numbered like any client, so it moves every later client's number by one, but it is never counted in SHOW POOLS.

Conditiondroid codeSQLSTATE
the server connection lost, or cannot be opened0192208006

What you should see when done

shell
$droid --server --port 5433 --db shop.db &
[SERVER] listening on 127.0.0.1:5433
$droid-pool --port 6432 --server-port 5433 --pool-size 2
[POOL] listening on 127.0.0.1:6432
[POOL] client 1 connected
[POOL] server 1 connected
[POOL] client 1 uses server 1
[POOL] client 2 connected
[POOL] client 1 closed
[POOL] client 1 released server 1
[POOL] client 2 uses server 1

Common bugs in this stage

  • Forwarding by read instead of by message. A read can end in the middle of a message. Cut whole messages on both sides, exactly as the server does.
  • Opening a server connection for every client. That is a proxy, not a pool. Open lazily, up to the size, and lend.
  • Forgetting the reset. The next client lands in the last one's transaction.
  • Forwarding the reset's answers. The client never asked for them.
  • One Query for the whole reset. It becomes an implicit transaction, and the SET is refused inside it.
  • Dying with the server. A lost server connection costs the client holding it, not the pooler.
  • Using the server's BackendKeyData. The client talks to the pool; its process id is the pool's client number.

Language notes

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

  • C: build droid-pool from the same server.c and wire.c with a different main; a ServerLink array of --pool-size entries and a FIFO of waiting clients.
  • C++: the same sources compiled into a second executable; a std::deque<int> of waiting clients.
  • Rust: a second binary in the same crate (src/bin/droid-pool.rs), sharing the wire module.
  • Zig: a second b.addExecutable in build.zig, importing the same wire module.

Checklist

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

  • Through the pooler, a client sees exactly what the server sends
    transparent
  • SHOW POOLS counts active and waiting clients, held and idle server connections
    show-pools
  • A released server connection is lent to the next client
    backend-reused
  • A connection is reset before it is lent again: no transaction, no rows, no prepared statements, no isolation level of the last client
    dirty-disconnect-reset
  • With every connection held, the next client waits, and is served when one is released
    pool-size-respected
  • When the server goes, its clients get FATAL 08006, and the pooler keeps running
    server-down