Skip to content

fix(memory): prepared statements cache retains gigabytes after statement spike - #1282

Open
IgorOhrimenko wants to merge 8 commits into
pgdogdev:mainfrom
IgorOhrimenko:prepared-statements-memory-accounting
Open

fix(memory): prepared statements cache retains gigabytes after statement spike#1282
IgorOhrimenko wants to merge 8 commits into
pgdogdev:mainfrom
IgorOhrimenko:prepared-statements-memory-accounting

Conversation

@IgorOhrimenko

@IgorOhrimenko IgorOhrimenko commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Problem

Long-lived clients that keep preparing unique SQL statements (report jobs, ORMs interpolating values into SQL) grow the global prepared statements cache to millions of entries. When they disconnect, the maintenance sweep correctly trims entries down to prepared_statements_limit — but the process keeps holding gigabytes of RSS indefinitely:

  1. HashMap/HashSet never return capacity to the allocator. After a spike of 10M statements, the three tables in GlobalCache (statements, names, unused) keep ~13.5M slots (~5.9 GiB) at 100 live entries, forever.
  2. prepared_statements_memory_used is blind to this: MemoryUsage for HashMap sums live entries via iter(), so it reports ~44 KiB while RSS holds ~10 GiB.

Reproduction (docker compose + load generator): https://gist.github.com/IgorOhrimenko/8ba71664c7cf27f005e9ef80a48cf97c

Measured on a synthetic load test (10M unique prepared statements at ~7k/s through one PgDog instance, then all clients disconnect; all values settled):

build peak RSS memory_used after disconnect capacity after disconnect RSS after disconnect
v0.1.50 15.5 GiB 44 KiB 10.1 GiB, held
+ accounting fix 15.5 GiB 5.91 GiB (real) 13 478 814 9.9 GiB, held
+ shrink fix 15.5 GiB 148 KiB (real — memory returned) 224 2.5 GiB

Changes

fix(stats): count hash table capacity in memory accounting

  • MemoryUsage for HashMap/HashSet now counts allocated slots (plus control bytes) and is generic over the hasher, so FnvHashSet is covered.
  • New prepared_statements_capacity gauge: capacity far above prepared_statements signals memory held from a past spike, which makes this failure mode visible on a dashboard.

fix(memory): shrink the tables in the maintenance sweep

  • close_unused() calls shrink_to(len * 2) on all three tables once they are mostly empty (capacity > 8 * len) and large enough to matter (> 4096 slots). Hysteresis avoids rehashing on every sweep; with jemalloc's background_thread (default since fix(memory): enable jemalloc background_thread by default #1260) the freed pages are returned to the OS within seconds.
  • reset() also releases capacity.

Peak memory under load is unchanged — entries held by connected clients are legitimate cache content; this PR is about returning the memory after they are gone.

Testing

  • Unit tests for the capacity accounting, the shrink, and the hysteresis guards (no shrink for small or mostly-full tables).
  • cargo nextest run --test-threads=1 over the workspace packages against a local Postgres — green.
  • Verified end-to-end with the load test above.

…ccounting

The global prepared statements cache reports memory usage by summing live
entries only. Hash tables allocate capacity, not len: after a spike of
unique prepared statements the spare capacity left behind dominates actual
memory use but is invisible to the metric, which reports near zero while
the process holds gigabytes.

Count allocated slots (plus control bytes) in the MemoryUsage impls for
HashMap and HashSet, make them generic over the hasher so FnvHashSet is
covered, and export the statements table capacity as a new
prepared_statements_capacity gauge: a capacity far above
prepared_statements signals memory held from a past spike.
Hash tables never return capacity to the allocator: after a spike of
unique prepared statements from long-lived clients, the global cache
tables keep gigabytes of empty buckets for the lifetime of the process.

Shrink the tables in the maintenance sweep once they are mostly empty
(capacity > 8x len) and large enough to matter (> 4096 slots), leaving
2x headroom to avoid rehashing on every sweep. Also shrink on reset().
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…ise metric help

The MemoryUsage trait mixes inline-size scalars with heap-counting
containers; state explicitly that aggregated numbers are upper-bound
approximations. Compare capacity against len * factor directly instead
of integer division, and say which table the capacity gauge measures.
Comment thread pgdog/src/stats/memory.rs
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::hash::Hash;

/// Approximate bytes attributable to a value, for metrics.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A note on the accounting precision, since it is not obvious from the diff:

MemoryUsage impls are mixed — scalars and small structs (usize, CacheKey, CachedStmt) report their inline size, while String/BytesMut report heap capacity. With the new capacity term in the HashMap/HashSet impls this means the inline portion of live entries is counted twice: once inside the table's capacity, once in the per-element sum.

The overcount is bounded by len * inline_size, which matters only when the table is full (a few hundred MB at millions of entries, against multi-GiB actual usage) and vanishes in the post-spike state this metric is meant to catch (few live entries, huge capacity). Making it exact would require splitting the trait into inline/heap halves across every impl, which does not seem worth it for an observability gauge — the doc comment on the trait now states the upper-bound semantics.

+ self.counter.memory_usage()
+ self.versions.memory_usage()
+ self.unused.len() * std::mem::size_of::<usize>()
+ self.unused.memory_usage()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember removing this because it was very expensive to calculate (O(n)).

/// statements. Hysteresis (mostly-empty table, above a minimum size)
/// avoids rehashing on every sweep; shrinking to twice the current
/// len leaves headroom for new statements.
fn maybe_shrink(&mut self) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread pgdog/src/stats/memory.rs
// The table allocates capacity() slots (plus one control byte each),
// not len(): spare capacity left behind by removed entries still
// occupies memory and has to be counted.
self.capacity() * (std::mem::size_of::<(K, V)>() + 1)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is O(n). We should be careful not to call this frequently. I had to debug CPU usage issues with this before.

rustfmt splits macro calls longer than its small-heuristics width, which
puts assert messages back on their own never-executed lines; the values
are in named locals right above, so the messages add nothing.
@IgorOhrimenko
IgorOhrimenko marked this pull request as ready for review July 29, 2026 17:12
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.

2 participants