Skip to content

RSS is not returned to the OS after bursts of large messages (jemalloc dirty pages retained; background_thread is off by default) #1230

Description

@IgorOhrimenko

Summary

Under bursts of larger-than-message_buffer messages at high client concurrency, PgDog's RSS grows to the burst peak (multiple GB) and never comes back down while the process is idle — it stays elevated until the process/pod is restarted.

This is not live memory held by PgDog: SHOW SERVER MEMORY accounts for only a few MB of buffers while RSS sits at several GB. The retained memory is jemalloc dirty pages that are never purged, because PgDog ships jemalloc with background_thread:false (the default) and the per-request buffer reclaim (MessageBuffer::shrink_to_fit, #592) frees large chunks back to an allocator that then holds onto them.

Setting _RJEM_MALLOC_CONF=background_thread:true fully fixes it (RSS returns to baseline within ~10s of the burst ending). Filing this so it can be addressed in PgDog itself (default/flag/config) and to leave a quick-fix for anyone who hits it.

Environment

  • PgDog 0.1.48.
  • Linux, x86_64, Debian-based container (ghcr.io/pgdogdev/pgdog:0.1.48).
  • Allocator: statically linked tikv-jemallocator (#[global_allocator]), jemalloc 5.3.0.
  • jemalloc defaults in the shipped image (from _RJEM_MALLOC_CONF=stats_print:true):
    • opt.background_thread: false
    • opt.dirty_decay_ms: 10000
    • opt.muzzy_decay_ms: 0
    • opt.narenas: 32
  • Transaction pooling, query_parser = "off".

Symptoms

  • After a nightly traffic burst, one or more pods' RSS jumps to ~5–7 GB and stays there for hours (until restart), tripping memory alerts.
  • The workload that triggers it is dominated by databases that move large messages (large result rows / large query payloads).
  • SHOW SERVER MEMORY on a ballooned production pod (RSS 4949 MB, 576 server connections):
    • SUM(total_bytes)7.1 MB
    • max buffer_bytes_alloc per connection ≈ 0.01 MB
    • prepared_statements_bytes ≈ 0.3 MB
    • → PgDog accounts for ~7 MB of live buffers while RSS is ~5 GB.

So the memory has been freed by PgDog but not returned to the OS.

Root cause (analysis)

  1. MessageBuffer::ensure_capacity reserves enough space for the whole incoming message. A large message grows the per-connection buffer to that size.
  2. After each request, MessageBuffer::shrink_to_fit (added in Reclaim space from buffer after every request #592, pgdog/src/net/messages/buffer.rs, called from frontend/client/mod.rs and backend/server.rs) re-allocates the buffer back down when it exceeded 2 * capacity, freeing the large chunk back to jemalloc.
  3. With background_thread:false (default), jemalloc only advances its decay / purges dirty pages on allocation activity in that specific arena. After a burst, the arenas that grew go idle → decay never runs → dirty pages are retained → RSS stays at the peak.
  4. With 32 arenas and a short, intense burst, a lot of dirty pages end up parked in arenas that subsequently go quiet, so a steady overall query rate does not clean them up (each thread's on-access decay only touches its own active arena).

muzzy_decay_ms:0 means there is no muzzy tier hiding the pages via MADV_FREE; they are genuinely retained dirty pages that a background purge would return via MADV_DONTNEED.

Minimal reproduction

The key ingredients are: high client concurrency, messages in jemalloc's arena size class (≈64 KB–4 MB, below the ~8 MB oversize/mmap threshold — huge single messages get munmap'd and released, so they do not reproduce it), and a small pool so many large client messages are buffered simultaneously while queued.

1. pgdog.toml

[general]
host = "0.0.0.0"
port = 6432
default_pool_size = 10          # small pool → client messages pile up buffered while queued
query_parser = "off"
passthrough_auth = "disabled"

[[databases]]
name = "lt"
host = "127.0.0.1"              # your postgres
port = 5432
database_name = "lt"

users.toml:

[[users]]
name = "lt"
database = "lt"
password = "ltpass"

2. Generate a load script with a ~3 MB client message (tiny result)

printf "SELECT octet_length('%s');\n" "$(head -c 3000000 /dev/zero | tr '\0' a)" > bigq.sql

3. Drive it: 500 concurrent clients for ~80s

PGPASSWORD=ltpass pgbench -n -c 500 -j 16 -T 80 -f bigq.sql \
  "host=127.0.0.1 port=6432 user=lt dbname=lt sslmode=disable"

4. Watch RSS of the pgdog process before / during / after

while true; do
  echo "$(date +%T) RSS=$(ps -o rss= -p "$(pgrep -x pgdog)") kB"
  sleep 3
done

Observed shape: RSS climbs to ~2 GB during load, then after pgbench stops it drops partway and plateaus well above baseline (retained dirty pages), staying there indefinitely while idle.

I ran this on Kubernetes (a throwaway postgres:18 + two PgDog deployments, default vs background_thread, driven by a pgbench pod). The full, self-contained manifest and the exact driver commands are here: https://gist.github.com/IgorOhrimenko/e0d77c8772cf6871b4851fd0afd80f2f — the commands above are the distilled, host-portable version.

Results

Same stand, same load (500 clients × 3 MB query, default_pool_size=10), baseline RSS ≈ 27–31 MB:

PgDog config Peak RSS RSS after ~60s idle
0.1.48 default 2018 MB 408 MB (stuck)
0.1.48 + _RJEM_MALLOC_CONF=background_thread:true 2017 MB 51 MB (returned)
0.1.48 + background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 2009 MB 38 MB (returned)
  • Same peak in all cases (the transient buffering is identical); the difference is purely whether jemalloc returns the freed pages.
  • Throughput/behaviour unaffected: ~7.5k transactions, ~90 tps, 0 failed in both default and background_thread runs.

Workaround

Set the jemalloc env var on the PgDog process/container:

_RJEM_MALLOC_CONF=background_thread:true

(tikv-jemallocator reads _RJEM_MALLOC_CONF, not MALLOC_CONF.) background_thread:true alone is sufficient with the shipped defaults (dirty_decay_ms:10000, muzzy_decay_ms:0). Optionally add dirty_decay_ms:5000,muzzy_decay_ms:5000 to return memory a few seconds faster; the difference is marginal.

Kubernetes example:

env:
  - name: _RJEM_MALLOC_CONF
    value: "background_thread:true"

Questions / possible fixes for maintainers

Since PgDog owns the global allocator, this seems addressable inside PgDog rather than relying on an undocumented env var:

  1. Enable background_thread by default (e.g. via tikv_jemallocator background_threads_runtime_support / a small mallctl call at startup, or a compile-time malloc_conf). This is the standard recommendation for long-running async servers and also smooths purge latency off the hot path.
  2. Expose a config/flag for allocator tuning (e.g. [general] jemalloc_background_thread = true / decay knobs), so it doesn't depend on _RJEM_MALLOC_CONF.
  3. Reconsider shrink_to_fit churn — reclaiming after every request frees large chunks back to an allocator that, by default, doesn't return them; a background purge (or a periodic mallctl arena.<i>.purge / .decay) would make the reclaim actually pay off in RSS.
  4. Document the _RJEM_MALLOC_CONF knob and this behaviour for operators.

Happy to test any patch/config against the reproduction above and report RSS curves.

References

  • pgdog/src/net/messages/buffer.rsMessageBuffer::ensure_capacity / shrink_to_fit
  • Reclaim space from buffer after every request #592 "Reclaim space from buffer after every request" (introduced the per-request realloc + SHOW CLIENT/SERVER MEMORY)
  • jemalloc TUNING.md — background_thread, dirty_decay_ms, muzzy_decay_ms

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions