PgBatchConnection: preparedStatementsMap_ grows unbounded; PostgreSQL backend memory exhaustion under long-lived connections
Summary
When LIBPQ_BATCH_MODE is enabled (the default when libpq supports pipeline mode), PgBatchConnection.cc caches one named prepared statement per unique SQL text in preparedStatementsMap_ for the lifetime of every connection, with no eviction, no size limit, and no DEALLOCATE. Workloads that legitimately produce a high number of distinct SQL texts (for example, multi-row INSERT ... VALUES (..),(..),.. statements where the row count varies) cause the per-connection map and the corresponding server-side CachedPlan / CachedPlanSource / CachedPlanQuery contexts to grow without bound. Each PostgreSQL backend's resident memory eventually reaches several hundred MB and can exhaust system memory under long-lived pooled connections.
Environment
- drogon: confirmed on
1.9.11,1.9.12,1.9.13, and currentmaster— the relevant code path inPgBatchConnection.ccis unchanged across all of them - libpq: pipeline mode supported (PostgreSQL 14+)
- Build flag:
LIBPQ_BATCH_MODE=ON(default), soPgBatchConnection.ccis compiled in place ofPgConnection.cc - PostgreSQL: 17.x (also expected to reproduce on 14+)
- Connection lifetime: long (hours to days, common with
DbClientconnection pools)
Reproduction
A minimal reproducer (note: preparedStatementsMap_ is per physical connection, so connNum=1 keeps the observation trivially simple):
- Configure
DbClientwithconnNum=1, withLIBPQ_BATCH_MODE=ON. (WithconnNum>1the SQL stream gets sharded across backends; you can still observe the leak but you have to aggregate per-backend stats.) - Submit a stream of unique SQL texts on that single connection. The simplest pattern is a multi-row
INSERTwhoseVALUES (..),(..),..row count varies between calls — every distinct row count produces a distinct SQL string. - Watch the PostgreSQL server-side memory of the backend process (e.g.,
pg_log_backend_memory_contexts(pid)for PostgreSQL 14+) grow over time, with hundreds to thousands ofCachedPlan/CachedPlanSource/CachedPlanQuerycontexts accumulating, each labelled with the user SQL text. - The client-side
preparedStatementsMap_grows in lockstep —preparedStatementsID_keeps incrementing and the map size matches the number of distinct SQL texts ever submitted on that connection. - The growth stops only when the connection is closed.
Minimal C++ reproducer sketch
#include <drogon/orm/DbClient.h>
#include <chrono>
#include <thread>
int main() {
auto db = drogon::orm::DbClient::newPgClient(
"host=127.0.0.1 port=5432 dbname=test user=postgres password=postgres",
/*connNum=*/1);
// SELECT-from-VALUES form: no rows written / no WAL / no table dependency —
// only the SQL-text variation matters. Every distinct row count yields a
// distinct SQL text, which becomes a permanent entry in preparedStatementsMap_.
constexpr int N_distinct = 2000;
for (int iter = 0; iter < N_distinct; ++iter) {
int rows = 1 + iter; // distinct row count -> distinct SQL text
std::string sql = "SELECT 1 FROM (VALUES ";
for (int r = 0; r < rows; ++r) {
if (r) sql += ",";
sql += "(0)";
}
sql += ") AS t(c)";
db->execSqlSync(sql);
}
// Hold the connection open. From psql:
// SELECT pg_log_backend_memory_contexts(<pid_of_this_backend>);
// Then look in the server log: hundreds of CachedPlan / CachedPlanSource /
// CachedPlanQuery contexts will be present, one per unique SQL text.
std::this_thread::sleep_for(std::chrono::hours(1));
return 0;
}Root Cause
In orm_lib/src/postgresql_impl/PgBatchConnection.cc:
sendBatchedSql()looks upcmd->sql_inpreparedStatementsMap_. On miss, it generates a new statement name vianewStmtName()(a monotonic counter++preparedStatementsID_formatted as a decimal string) and callsPQsendPrepare(...).- The map is recorded once
PQsendPreparesucceeds and is otherwise never modified during the connection's lifetime. There is no eviction policy, no maximum size, no LRU, noDEALLOCATEissued to the server, and no shrinking on memory pressure. - The simple-protocol path (
PgConnection.cc, used whenLIBPQ_BATCH_MODE=OFF) bypasses the cache only forparaNum == 0(PQsendQuery); itsparaNum > 0branch has the same unboundedpreparedStatementsMap_shape.
(Verified still present on current master as of 2026-06-16: the map is populated but no eviction / DEALLOCATE / PQclosePrepared path exists.)
Why it matters
Some workloads legitimately vary the SQL text: multi-row INSERT batching where batch size depends on a runtime queue snapshot, dynamically composed optional WHERE filters, etc. In each case the unbounded server-side CachedPlan accumulation eventually translates to backend RSS exhaustion; connection pools with long idle timeouts amplify it because only closing the connection frees the memory. This matches general PostgreSQL guidance that a client-side prepared-statement cache must be bounded and must DEALLOCATE evicted entries server-side (Rails, JDBC, etc. ship bounded caches).
Proposed Fix
- Bound the whole prepared-statement cache (map entry + the backing SQL string + per-entry metadata removed atomically — an LRU on the map alone leaves the string container growing).
- Default cap (e.g. 256), configurable via a
DbClientparameter;0= unlimited to preserve current behaviour. - On eviction, issue a safe server-side close for the statement:
- libpq ≥ PG17:
PQsendClosePrepared(asyncClose, fits pipeline mode). - libpq < PG17: compile-time gate (
#if PG_VERSION_NUM >= 170000); fallback uses an extended-protocolDEALLOCATE "<name>"(note:newStmtName()yields a numeric-leading name, so it must be double-quoted) viaPQsendQueryParams, or exit/re-enter pipeline mode around a synchronous deallocate. - Evict only at a safe pipeline point where the name is not referenced by an in-flight
EXECUTE(queue the close after the nextPQpipelineSyncresults drain). - Maintenance commands emit their own
PGresult; tag the in-flight command (e.g. anisMaintenance_flag onSqlCmd) so the dispatcher routes maintenance results to an internal path instead of user callbacks, preserving FIFO ordering.
- libpq ≥ PG17:
- Apply the same fix to the
paraNum > 0branch ofPgConnection.cc. - Document the new option.
Workarounds in the meantime
- Build with
-DLIBPQ_BATCH_MODE=OFFand use onlyparaNum == 0SQL paths (simple query path does not populate the cache). - Set a server-side
idle_session_timeoutso backends recycle periodically (containment, not a fix).
Pull Request
I am preparing a PR that implements the bounded-cache + safe deallocate fix and will link it to this issue. Open to maintainer input on: default cap size (256 is a starting guess); whether to expose a stats/metrics surface (evictions counter, current size) on DbClient; and whether the extended-protocol Close is preferred over DEALLOCATE SQL for the eviction path in pipeline mode.
Source: drogonframework/drogon