Reproduces a memory balloon in PgDog caused by the AST query cache
(query_cache_limit, default 1000), not by prepared statements and not by
allocator retention.
PgDog keeps one global LRU cache of parsed query ASTs per process
(static CACHE, shared across all clients and all databases the instance
fronts), keyed by the query text. The limit bounds the number of entries
(1000), not the bytes. A parsed tree for a wide/complex query is large
(a few MB here, 5-6 MB for real reporting queries), so:
- 1000 heavy entries -> multi-GB RSS. The limit can't prevent this — it caps count, not memory.
- Entries leave the cache only by LRU eviction (1000 newer distinct
queries push them out), by
RESET QUERY_CACHE, or by restart. There is no TTL / idle expiry. So a burst of heavy queries (e.g. a nightly report) fills the 1000 slots and the RSS stays ballooned until enough new distinct queries arrive to evict them — which, on a low-diversity daytime workload, may not happen before the pod is restarted.
The memory is live (the cached entries), not freed-but-retained:
background_thread:true (or even dirty_decay_ms:0) does not release it,
because the pages hold live allocations.
docker compose up -d
pip install 'psycopg[binary]'
python3 repro.py # run from this directoryrepro.py drives three phases and prints pgdog RSS + query_cache_size.
| phase | what it does | RSS | cache_size |
|---|---|---|---|
| baseline | — | ~30 MB | 0 |
| P1 | 2000 distinct heavy queries | ~3 GB | 1000 (heavy) |
| P2 | 120 s light traffic, 100 fixed (hits, no turnover) | stays ~2.5 GB | 1000 |
| P3 | 2000 new distinct light queries (evict heavy) | -> ~150 MB | 1000 (light) |
Takeaways:
- Heavy queries fill the cache -> balloon. ~3 MB/entry here; real report queries are heavier (hence 5-6 GB in production).
- While the cache doesn't turn over, memory is held. P2: light hits keep the heavy entries in the LRU, RSS stays at ~2.5 GB. Matches production during a low-diversity daytime after a nightly report.
- Eviction frees it without a restart. P3: new distinct light queries push
the heavy entries out of the LRU; RSS drops back to baseline as
background_threadreturns the pages. Same effect asRESET QUERY_CACHE.
Note cache_size is pinned at 1000 the whole time — the LRU count is bounded;
the memory is not, because entry size varies by orders of magnitude.
- Lower
query_cache_limit(bounds count -> bounds worst-case memory; costs cache hit-ratio, so tune per workload). - A byte-based cache limit (cap total memory, not entry count) and/or a time-to-idle (evict entries not used within N seconds) would fix this without the count/hit-ratio trade-off. Both validated locally; not upstream yet.