Repository navigation
Make shadow PostgreSQL settings configurable - #242
Closed
iskakaushik wants to merge 1 commit into
Closed
iskakaushik wants to merge 1 commit into
iskakaushik wants to merge 1 commit into
Conversation
materialize_conf rewrites the managed shadow's postgresql.conf on every start with shared_buffers = 256MB, max_wal_size = 512MB and min_wal_size = 128MB, and empties postgresql.auto.conf. Operators cannot tune the shadow, and ALTER SYSTEM on it is lost at the next restart. Those values cost a production deployment that kept walshadow's filtered WAL, the shadow's pg_wal and about 4.5 TB of shadow data on one EBS volume. Each source WAL byte was written three times: to out/, to pg_wal by the walreceiver, and to zero-fill each new segment first (WalInitWrite and WalInitSync waits). A restartpoint recycles only segments within max_wal_size of its redo point, and at 512MB a draining backlog outruns that window: in a benchmark, restartpoints removed segments, recycled none, and every new segment was zero-filled. Restartpoints also ran on WAL volume every few hundred MB, each flushing about 200 MB. Once the volume ran out of burst credits and was capped at 312.5 MB/s, TOAST fetches on the shadow went from about 120 us to 250-800 us, with 256 MB of buffers in front of a multi-TB TOAST relation. Add a [shadow] table of postgresql.conf settings, written ahead of the settings walshadow owns. [shadow] may not name those: recovery and streaming wiring, sockets, the bridge's preload and the floor the source's GUCs set. Names must be lowercase setting names, so no entry can alias an owned one or break the conf grammar. shared_buffers, max_wal_size, min_wal_size and checkpoint_timeout need a unit, because PostgreSQL reads a bare number in the setting's base unit (8 kB pages for shared_buffers). PostgreSQL validates everything else at startup, and a failed start already carries the shadow's log tail. Change three defaults: - wal_init_zero = off writes only a new segment's last byte. ext4 and XFS stay crash-safe because fdatasync persists the block allocation with the data; a full disk then fails a WAL write rather than segment creation. Allocating as WAL arrives adds write latency, which showed as slower TOAST fetches on a disk with spare bandwidth, but halving the walreceiver's writes dominates once bandwidth is the limit. Copy-on-write filesystems can also set wal_recycle = false, which stays on and is listed with it. - max_wal_size = 8GB leaves restartpoints to checkpoint_timeout below about 14 MB/s of WAL and keeps the recycle window ahead of a draining backlog. pg_wal can grow to about that size, which small disks have to allow for. - shared_buffers takes an eighth of the cgroup or host memory limit, from 256MB to 8GB, the same dedicated-host assumption as the resident_payload_max default, which takes half. In shadow value mode the shadow serves TOAST reads and replays TOAST writes, and at 256MB replay keeps evicting dirty buffers, so the bridge writes pages before it can read. Hosts under 2 GB keep 256MB, and a catalog-only shadow leaves most buffers untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
serprex
reviewed
Oct 24, 2026
|
|
||
| /// PostgreSQL reads a bare number in the setting's base unit, 8kB pages for | ||
| /// `shared_buffers`, so these take a unit | ||
| fn units(name: &str) -> Option<&'static [&'static str]> { |
Member
There was a problem hiding this comment.
we should just embrace bare numbers over incomplete list
serprex
reviewed
Oct 24, 2026
| } | ||
|
|
||
| /// A number, optional spaces, then one of `units` | ||
| fn has_unit(value: &str, units: &[&str]) -> bool { |
Member
|
created #278 as simpler alternative. tuning shared_buffers should be handled outside of walshadow |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds on #222, now merged.
Problem
Shadow::materialize_confrewrites the managed shadow'spostgresql.confon every start withshared_buffers = 256MB,max_wal_size = 512MBandmin_wal_size = 128MB, and emptiespostgresql.auto.conf. Operators cannot tune the shadow, andALTER SYSTEMon it is lost at the next restart.In a production deployment the shadow held about 4.5 TB on a host with about 66 GB of RAM, with walshadow's
out/segments, the shadow'spg_waland the shadow's data on one EBS volume:out/, to the shadow'spg_wal, and to zero-fill each new segment first (WalInitWrite/WalInitSyncwaits).max_wal_size = 512MB, restartpoints ran constantly (restartpoint starting: wal, each flushing about 200 MB).fetch_toastservice time on the shadow rose from about 120 µs to 250-800 µs, with 256 MB ofshared_buffersin front of a multi-TB TOAST relation.Change
A
[shadow]table in the ch-config TOML holdspostgresql.confsettings for the managed shadow. walshadow writes them ahead of the settings it owns, each time it starts the shadow.Validation fails config load instead of ignoring the entry:
hot_standby,wal_level,fsync,full_page_writes,autovacuum,listen_addresses,unix_socket_directories,port,restore_command,wal_retrieve_retry_interval,shared_preload_libraries,dynamic_library_path, the floor-derivedmax_connections/max_worker_processes/max_wal_senders/max_prepared_transactions/max_locks_per_transaction, anything starting withprimary_,recovery_orwalshadow., plusconfig_file,data_directory,hba_file,ident_fileand theincludedirectives.shared_buffers,max_wal_size,min_wal_sizeandcheckpoint_timeoutneed a unit, with PostgreSQL's case-sensitive spelling. PostgreSQL reads a bare number in the base unit, 8 kB pages forshared_buffers.Precedence follows the existing TOML rules (fragments in
ch-config.d/merge per key). There is no CLI flag or source-side row for these settings. They apply when walshadow starts or restarts the shadow; an adopted--keep-shadow-runningshadow keeps its running values until it restarts. The settings live in their own module,src/catalog/shadow/settings.rs, andBridgeConfis untouched, so this rebases cleanly against the compact shadow stream work in #223.Defaults
wal_init_zeroonofffdatasyncpersists the block allocation with the data. The cost: a full disk fails a WAL write rather than segment creation, and allocating as WAL arrives adds some write latency (see the unthrottled variant below). Settruewhere bandwidth is plentiful or space must be reserved; on ZFS/btrfs also setwal_recycle = falsewal_recycleononwal_init_zeromax_wal_size512MB8GBmax_wal_sizeof its redo point. At 512MB the walreceiver outruns that window: the main runs below log restartpoints with54 removed, 0 recycled, so every segment is created and zero-filled. 8GB leaves restartpoints tocheckpoint_timeoutbelow about 14 MB/s of WAL.pg_walcan grow to about this size, more when source checkpoints are rare. 16GB would halve WAL-driven restartpoints again at twice the diskmin_wal_size128MB128MBshared_buffers256MBresident_payload_maxdefault, which takes half of the same limit, with the same dedicated-host assumption. Hosts under 2 GB keep 256MB; a 66 GB host gets 8GB. A catalog-only shadow touches few buffers, unless the host has free huge pages, which PostgreSQL then reserves for all of themNames, units and contexts were checked against REL_16_STABLE through REL_19_STABLE:
shared_buffers(postmaster, 8 kB blocks),max_wal_size/min_wal_size(sighup, MB),checkpoint_timeout(sighup, s, 30s-1d),wal_init_zero/wal_recycle(superuser, reloadable),effective_io_concurrency(user). The memory and time unit tables are identical across these versions. Since walshadow rewrites the conf only at start, reloadable settings still take effect at the next shadow start.Benchmark
These runs use bench/ceramic from #229 on a 64-core x86 host with 123 GB RAM and an EBS volume. The golden snapshot was prepared from main 97f9305: PG 18.6 source and shadow,
[toast] mode = "shadow", a backlog of 4.25 GB / 5.64M records. The daemon was pinned to 8 CPUs. A is main 97f9305 and B is this branch. Each table isab.shwith 4 interleaved rounds per side, shown as mean ± stdev. Other jobs shared the host, and the load per run is noted.I added these measurements to a local copy of
run.sh(they are not in #229):/proc/<pid>/iowrite_bytes − cancelled_write_bytesfor the daemon and the shadow's live processes over the drain window;io.statfrom drain start through asyncafter the drain;The throttled case runs everything (daemon, shadow, source, ClickHouse) in a cgroup with
io.max rbps=wbps=312 MB/son the EBS device. Source data and ClickHouse are on tmpfs, as in #229.Unthrottled (load 2.5-4.9)
Write-throttled, 312 MB/s read and write (load 5.5-10.4; main drops to 45% of its unthrottled rate, so it is disk-bound)
Page cache dropped for the shadow's
base/after restore (dd iflag=nocache count=0per file, so first reads go to EBS)When fetches are cold reads, both sides wait on EBS reads, so most of the cache gain above relies on the recent TOAST working set fitting in buffers. The gain that remains with a cold cache is the write savings, and it grows when the disk is capped.
Where the shadow's writes go (single unthrottled runs, bytes per source WAL byte)
Effect of each default (separate
ab.shsessions, 3-4 rounds each; the rows between main and the last run this PR's code with the listed values)The unthrottled column mixes sessions with load between 2.5 and 11, so read it as direction only. Under the cap,
wal_init_zero = offgives +33% on its own. With 256MB buffers it costs a few percent and about 9 µs of c_fetch on the uncapped disk, and the larger buffers remove that cost.max_wal_sizealone barely moves this benchmark, because its 4.25 GB backlog is shorter than one 8GB restartpoint interval (about 4.3 GB of WAL). The PR runs show no restartpoints, and therefore also no recycling. Under sustained load the shadow still restartpoints about every 4.3 GB of WAL, against about 270 MB before, and the segments it recycles need neither zero-fill nor allocation.Tests
ShadowSettingsunit tests: every size and duration setting parses exactly when its unit is one of PostgreSQL's units for that kind, matched case-sensitively, and fails for a bare number or a unit with no number. Names that could inject lines or alias an owned setting are refused.materialize_conf_writes_each_setting_once, over default and overridden settings × no bridge, a bridge and a bridge with a library dir × with and without a walsender: each name appears once, each[shadow]setting is written with its value, and[shadow]refuses every other name walshadow writes. It replaces the conninfo-absent example test.shadow_lifecycle::standby_mode_lifecyclestarts a standby with[shadow]overrides (including a quoted value with'and\) and reads every setting back withSHOW. It passes on PG 17 and 18.[shadow]throughEmitterConfiginto the owned shadow's conf, and rejections of an owned key and a bad unit.PATH=/usr/lib/postgresql/17/bin:$PATH WALSHADOW_REQUIRE_TOOLS=1 cargo nextest run --workspace --all-targets --locked: 1689 passed, 7 skipped. The WAL fixtures were regenerated withcapture.shfirst, as CI does.cargo fmt --check,cargo clippy --workspace --all-targets -- -D warnings.🤖 Generated with Claude Code