Skip to content

A per-connection prepared-statement cache, and constants every worker may read - #2

Merged
sdogruyol merged 7 commits into
masterfrom
perf/statement-cache
Nov 2, 2026
Merged

sdogruyol merged 7 commits into
masterfrom
perf/statement-cache

Conversation

@sdogruyol

Copy link
Copy Markdown
Contributor

Closes #1.

What

A per-connection prepared-statement cache. A statement's first run on a connection sends Parse under a generated name and Describe statement, together with Bind/Execute/Sync, in one round trip. Later runs send only Bind, Execute and Sync.

  • Key: the SQL text. Parse sends no parameter types, so the server infers them from the text.
  • Bound: an LRU of statement_cache_capacity entries, default 512 as in pgx. An evicted statement is closed at the start of the next exchange.
  • Off switch: statement_cache_capacity=0, or iyi-db's prepared_statements_cache=false. Unprepared statements, BEGIN and COMMIT keep the unnamed Parse.
  • Out-of-date statements: a statement the server refuses as out of date (26000, 0A000, class 42, or class 22 with parameters) is dropped and prepared again. Outside a transaction block the query is retried once. Inside one the error is returned, and the statement is prepared again after the transaction.
  • Invalidation: DISCARD ALL and DEALLOCATE ALL empty the cache, and so does any CREATE, ALTER or DROP on the same connection.

Constants are now Share, for parallel code on iyi's multi-core scheduler:

  • ConnInfo's tables and QUOTED are Lists.
  • LOG and the replication logger sit behind a Mutex.
  • DECODERS is a registry that builds a frozen, Share table. Each connection keeps a table, so reading a column takes no lock.
  • @[SafeReach] is not used, so pg still builds on iyi 0.18.0.

Compatibility

  • User-defined decoders must now be Share.
  • ArrayDecoder, RecordDecoder, RangeDecoder and MultirangeDecoder return a copy of their OIDs.

Measured

  • Messages per query (1000 queries on one connection, counted through a proxy): Parse, Bind, Describe, Execute and Sync before. After: Bind, Execute and Sync, plus one Parse and one Describe statement in total.
  • Postgres CPU per query: 40–49 % lower. PostgreSQL 14.19 on macOS 26.6, Apple M2 Pro.
  • A real web app on Windows 11 with 12 logical processors and 12 workers, over 11 database-bound GET endpoints, against its Go port (pgx):
    • throughput went from 0.60 to 0.96 of Go's;
    • postgres CPU per request went from 26.2 ms to 14.9 ms, against Go's 14.1.

Tests

  • iyi test pg: 20 of 21 files pass. pg/pq/authentication_methods_test.iyi fails on master too: it needs PGPORT and scram lines in pg_hba.conf.
  • New pg/statement_cache_test.iyi:
    • a hit after first use, counted in Parse messages;
    • LRU eviction, checked in pg_prepared_statements;
    • an ALTER TABLE from another session, in and out of a transaction;
    • a column type changed under a parameter;
    • an error inside a transaction;
    • DEALLOCATE and DISCARD;
    • reuse across pool checkouts;
    • the off switches.

Known limit

A migration from another session that changes a column to a type the value still converts to without error is not detected (e.g. timestamp → timestamptz on a server not in UTC). pgx has the same limit. The README says to run such migrations with the cache off, or to reconnect after them.

#1)

A prepared statement's first execution on a connection parses it under a
name of the session's statement cache and describes it, in the same
exchange as its Bind and Execute; later executions send only Bind,
Execute and Sync. The cache is an LRU of statement_cache_capacity entries
(512, pgx's default; 0 turns it off, as does prepared_statements_cache=false),
a Close for each statement it gives up going with the next exchange.

A kept statement the server refuses to bind as out of date - 26000, 0A000
(cached plan must not change result type), class 42, or class 22 with
parameters - is given up and the query prepared anew once, when the error
left no transaction block aborted. DISCARD ALL and DEALLOCATE ALL empty
the cache, and DDL on the same connection gives up every kept statement,
whose parameter types were inferred against the schema before it.
Unprepared statements keep the unnamed Parse.
They are only read, from whichever worker a connection is opened or a
range is bound on; as Arrays each was a #148 reach warning in every
parallel block that opens a connection.
std's Logger is not Share: level=, backend= and for change it in place,
and a connection logs from whichever worker its task runs on. SharedLog
holds Log.for(source) in a Mutex and builds a message only when the
logger takes its severity. The tests set the backend on Log.for("pg"),
the same logger.
DECODERS was a Hash every column read looked up and register_decoder
wrote, from any thread. Registrations now go into a registry behind a
Mutex, counted by an Atomic, which builds a DecoderTable of them - the
OIDs sorted in a List, their decoders in another - when first asked after
one. A connection keeps the table it was given and asks again only after a
registration, so a column is read without a lock; Decoders.from_oid, which
records, ranges and typed arrays use for what they hold, takes the lock.
The decoders that held their OIDs in an Array hold a List, so a decoder
is Share.
README: Prepared statements, statement_cache_capacity, Share decoders,
and the crystal-pg differences. CHANGELOG: the unreleased changes, with
the measurements on macOS, and why @[SafeReach] is not used.
@sdogruyol
sdogruyol merged commit f38a2ab into master Nov 2, 2026
1 check passed
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.

Every execution re-Parses its statement: about twice the server CPU per query of a statement cache

1 participant