walshadow runs co-located Postgres as schema-only catalog mirror & decode oracle. Shadow replays catalog WAL via streaming replication plus archive fallback; decoder queries its catalog over libpq. Shadow never serves user-heap data, never gets DDL'd by walshadow, never accepts writes from anywhere but source WAL feed
Two surfaces:
- lifecycle — process management (config,
pg_ctl), bootstrap restore, recovery startup, supervision.Shadowinsrc/catalog/shadow.rsprovides operations. Daemon owns full lifecycle whenever--bootstrap-shadow-data-diris set - catalog API — async libpq client: batched descriptor fetches for
capture (desc_log.md), name-keyed resolution for opt-in
and backfill standup, replay-LSN gate. Owned by
ShadowCataloginsrc/shadow_catalog.rs. Decode never queries it
Lifecycle code is sync, shells out to PG binaries; catalog code is
async, drives tokio-postgres. They share data dir & port but
otherwise compose at daemon level
Shadow (src/catalog/shadow.rs) wraps
initdb, pg_ctl, psql, and config files (postgresql.conf,
pg_hba.conf, standby.signal, restore_command,
primary_conninfo) in one struct. Daemon boot order with
--bootstrap-shadow-data-dir:
- Choose bootstrap or resume. Bootstrap only an empty data dir
according to
--bootstrap-mode(see bootstrap.md). Resume initialized cluster regardless of mode. Ifwalshadow_bootstrap.incompleteexists, fail without changing data dir. Never turn standby recovery failure into automatic rebootstrap - Run
write_standby_signal. Standby signal keeps shadow in recovery while it receives continuous WAL stream - Run
control_guc_floorandmaterialize_conf(floor, primary_conninfo). They read five minimum GUC values from shadow'spg_controlwithpg_controldata.LC_ALL=Ckeeps output labels stable. PostgreSQL checks these values againstpg_control, so reading them locally matches WAL being replayed and avoids querying source. Current source settings can differ from values required by older WAL. For example, shadow withmax_connections = 100cannot start whenpg_controlrequires 500. Replacepostgresql.confwith walshadow settings (port, unix socket,autovacuum = off,fsync = on,hot_standby = on,wal_level = replica,listen_addresses = ''),restore_command = 'cp <filter_dir>/%f %p',recovery_target_timeline = 'latest', andprimary_conninfo = '<walsender>'. Emptypostgresql.auto.confto remove sourceALTER SYSTEMsettings included by BASE_BACKUP. Write socket-onlypg_hba.confusing trust authentication and emptypg_ident.conf. Do not use config files from backup because Debian stores them outside data dir under/etc/postgresql/<v>/<cluster> - Run
clear_stale_pid, thenstart_with_floor_retry.pg_ctl -w startwaits for postmaster to accept connections (~600 ms on PG 18 in standby mode). WAL can raise required GUC values during startup. PostgreSQL first updatespg_control, then aborts startup. On failure, read new values and retry. Return error if values did not change. Include end ofstartup.logbecausepg_ctlonly reports "could not start server" while log includes required value. After a fresh bootstrap, runwait_for_replay(end_lsn, timeout)against WAL included in backup - Call
is_runningevery 2 s. If postmaster stops, restart it with backoff and read minimum GUC values again. Hot standby can pause replay when WAL requires higher value. Detect a pause withpg_get_wal_replay_pause_state(), then confirm the cause: a floor raise writes the higher value topg_controlbefore pausing, so acontrol_guc_floorabove the runningcurrent_settingvalues marks it. Only then callpg_wal_replay_resume(); resume shuts server down, allowing restart with updated values. A pause with floor equal to running settings (operatorpg_wal_replay_pause, recovery target) holds untouched. On daemon exit, runpg_ctl stop -m fastso data dir is ready for next startup - Run
healthto check recovery state, replay LSN,pg_classcount, andpg_proclookup in one corruption probe
After bootstrap marker clears, every later start is standby recovery. WAL unavailability leaves recovery waiting or restarting against configured WAL sources; it never invokes bootstrap or replaces data dir
Tests use initdb to create empty cluster (~50 MiB, ~400 pg_class
rows), call write_base_conf, restore schema with
apply_schema_dump(sql), then call
enable_standby_recovery(primary_conninfo). apply_schema_dump sends
pg_dump --schema-only output to psql -f - and accepts &str, not
source connection
Probes route through psql -tAXq -c via psql_one helper. Real libpq
client lives in ShadowCatalog; mixing the two at this layer would
duplicate connection state for no measurable win
See architecture/shadow_communication.dot for rendered diagram:
- libpq catalog queries —
ShadowCatalog's tokio-postgres client. One long-lived connection over unix socket for descriptor capture's batched fetches, name-keyed resolution, andwait_for_replay. Boundary-rate, never per record: decode reads the descriptor log - walsender wire —
ShadowStreamSinkframing filtered-record bytes as'w'XLogDataCopyData frames, listener accepts shadow's walreceiver (primary_conninfoin shadow's conf). Record-cadence WAL push, ms-scale. See source.md for source-side walsender walshadow itself consumes - restore_command archive fallback —
cp out/%f %pcopies completed 16 MiB segments from filter output dir. Startup recovery uses it after wire disconnects and while catching up after restart. Retention keeps segments back to shadow's last restartpoint, see ops.md
Channels (2) & (3) coexist by PG design: walreceiver tries
primary_conninfo first, falls back to restore_command on connect
error or end-of-WAL. Both feed shadow's startup recovery which advances
pg_last_wal_replay_lsn(); channel (1) reads that LSN as gate input
Async libpq client over shadow's unix socket. Key surfaces:
pub async fn fetch_descriptors_batch(&mut self, oids: &[Oid])
-> Result<(u64, Vec<RelDescriptor>)>; // + replay position
pub async fn fetch_all_descriptors(&mut self)
-> Result<(u64, Vec<RelDescriptor>)>; // capture-all / boot seed
pub async fn descriptor_by_name(&mut self, rel: &RelName)
-> Result<Option<Arc<RelDescriptor>>>; // opt-in dispatch
pub async fn wait_for_replay(&mut self, target: u64) -> Result<u64>;
pub async fn fetch_overlay_descriptors(&mut self,
oids: &[Oid], top_xid: u32, boundary: u64)
-> Result<Vec<RelDescriptor>>; // uncommitted DDL, see belowNo cache, no invalidation, no event channel: descriptor history lives
in the durable log (desc_log.md); capture calls these
fetchers only at catalog boundaries with shadow already applied through
the boundary's next_lsn, so the snapshot is exactly the commit's
state. Foreign-db rejection likewise moved to the log's lookup surface
(LookupResult::ForeignDb). DROP discovery is capture-native: an oid
absent from a boundary's fetch with a Present predecessor tombstones +
emits Dropped — no polling sweep
Every fetch above assembles its RelDescriptor in Rust from the catalog
projections bridge worker's SCAN op names (oracle.md):
pg_class, pg_attribute, pg_index, plus oid → name maps for
pg_namespace and pg_type. Committed reads pass top xid 0, which owns no
transaction, so visibility predicate degenerates to committed view;
fetch_all_descriptors passes no oid list, which reads whole pg_class and
scopes remaining projections to returned relations. Oid lists longer than one
request may carry are chunked, every chunk pinned to one position
Committed reads have a second source: one SQL statement mirroring those
projections, carried as (catalog id, text[]) rows so both reach the same
parsers, and one statement so one snapshot covers every projection. Values are
each type's text output form, which is format('%s', v) and not a ::text
cast — the cast renders a boolean true where boolout says t, and takes
int2vector out of its space-separated form. It answers whenever the worker
cannot hold one replay position across a read
Physical columns come straight off pg_attribute
(attbyval/attlen/attalign/attstorage): DROP COLUMN zeroes atttypid but
preserves those, so a dropped slot keeps its layout and its type_name goes
empty for want of a pg_type row. Dropped slots stay in attributes, keeping
attnum-1 indexing exact. reltablespace arrives raw and the 0 =
database-default sentinel resolves against a memoized
pg_database.dattablespace. relfilenode is the column, not
pg_relation_filenode(), which reads through relcache and cannot see an
overlay; mapped relations therefore report 0, and no user relation is ever
mapped. All pg_index rows for a relation come back and picking the primary
and replident rows is this side's.
SQL sees committed rows only, so a transaction whose DDL is still open is
invisible to it. fetch_overlay_descriptors asks the worker for the same
projections under the requesting transaction's own view: its inserts present,
its deletes applied.
Caller parks replay at a boundary LSN and passes it; both replay positions the
worker samples must equal it, or the read fails rather than describing a
different point in WAL. A committed read has no boundary of its own and takes
the first scan's position, pinning the rest to it — a replay move mid-read
would otherwise tear the descriptor across two points in WAL, since
SnapshotAny gives the worker nothing to hold still against.
Replay only sits still inside the publication hold; away from one it moves
between requests and no sequence of scans answers for a single position, so a
committed read redoes itself on the mirroring statement. An overlay read fails
instead: the caller holds the boundary the question is about, and an MVCC
snapshot cannot see the rows it asks for. Movement increments
walshadow_bridge_scan_replay_moved_total, which reads two ways — expected off
a boundary, a boundary-hold bug during capture
Namespace and type names resolve against the committed catalog first. The whole-catalog scan behind them has no oid list and so no relation-lock argument, and refuses to answer while any foreign writer is mid-DDL, so it runs only for oids the committed read did not have, ie ones this transaction created.
Two rows for one oid, or for one (attrelid, attnum), fail the read: that is a
superseded row version surviving the worker's visibility predicate, which would
shift every later column.
ShadowCatalog stashes conninfo at construct time; diagram's
reconnect path triggers transparently on client close (shadow bounce,
OOM kill, supervisor restart):
reconnect()&ensure_open()are private async fns onShadowCatalog. Earlier notes presented them aspubfor illustration; implementation kept them internal because every external call routes throughquery_*_retryhelpers which bracket SQL withensure_open+ one-shot retry onclient.is_closed()last_replay_lsnresets on reconnect to avoid stale monotone-tracking shortcut against freshly-restarted standbywith_transient_retry(timeout, async-closure)free function wraps any catalog op in exponential backoff (default 100 ms initial / 1 s ceiling, capped byreplay_timeout).is_transientmatches everyCatalogError::Pg(_)variant — fine-grained classification is follow-up if a workload measures spurious retries
Single retry inside query helpers, multi-attempt budgeted retry outside: keeps cache bookkeeping unaware of in-flight retries, keeps backoff policy varyable per call site
What catalog produces per relation:
rfn: RelFileNode,oid: Oid,namespace_oid,rel_name: RelName(structured{ namespace, name }pair,Arc<str>parts for hot-path routing; joined only at SQL interpolation /Display)kind(pg_class.relkind:'r'table /'p'partitioned / etc),persistence('p'/'u'/'t')replident: ReplIdent— resolved frompg_class.relreplidentthroughpg_index:Default { pk_attnums },Nothing,Full,UsingIndex { index_oid, key_attnums }. Carries indexed-attnum list inline so old-tuple decode underXLH_UPDATE_CONTAINS_OLD_KEYresolves without a second round-tripattributes: Vec<RelAttr>— per column:attnum,name,type_oid,typmod,not_null,dropped,type_name,type_byval,type_len,type_align,type_storage,missing_text(PG 11+ fast-pathADD COLUMN ... DEFAULT k, carried as typoutput rendering)
Dropped columns stay in attributes (dropped = true) because
heap-tuple decoder needs them to walk null bitmap correctly; consumers
filter at use-site. See decoder.md
Shadow-stream sink composing alongside DirSegmentSink &
BufferingDecoderSink on WalStream. Per-record dispatch:
on_wire_chunk(start_lsn, bytes) ships rewritten record bytes plus
page-header & inter-record padding bytes preceding them (walreceiver
rejects records arriving at non-page-aligned LSNs without their page
headers — "invalid magic 0000"). CopyData wrapping at enqueue via
wrap_copy_data so listener concatenates multiple frames in one
write_all
Per-connection state:
dispatched_lsn(mirrors source'swrite_lsn)flush_lsn,apply_lsn(from inbound'r'standby status frames)closing(set on write error, drops slot on next sweep)
Aggregate view (ShadowStreamState::aggregate() → AggregateLsn)
exposes min_flush_lsn, min_apply_lsn, active_connections,
dropped_total for status loop + metrics
Backpressure: per-connection send queue caps at slow_threshold bytes;
overflow drops socket & lets shadow reconnect — completed segments via
archive (restore_command), in-progress segment via wire_buf
backfill at register (else reconnect strands on an unappliable gap at
segment boundary). Listener injects 'k' keepalive past 10 s idle so
walreceiver flushes & replies without fresh WAL. server_wal_end
advanced only to bytes already enqueued — advertising higher value
crashes PG 18's walreceiver on still-zero page it tries to read
Segment cadence preserved on top of record cadence: DirSegmentSink
still writes one 16 MiB segment + manifest per boundary. Wire is hot
path, segments are archive fallback + durable artifact
Produced solely by descriptor capture as log diffs
(desc_log.md); they enter the xact buffer as drain
entries keyed (drain_xid, valid_from) and apply inside the reorder
barrier. Variants (see diagram legend for trigger → DDL mapping):
Added { desc }— no log predecessor (CREATE, or a rel entering an existing log via capture-all discovery); boot re-appliesAddedfor the active Present set each start (idempotent CH DDL)Changed { old, new, diff: SchemaDiff }—SchemaDiffcarriesadded_columns,dropped_columns,renamed_columns,type_changes. Renames detected by attnum-match + name-diff heuristic; PG'sRENAME COLUMNkeeps attnum intact, natural case lands hereDropped { oid, rel_name }— oid absent from a boundary's capture with a Present predecessor; works forrelreplident = 'n'catalogs too (no old-tuple decode needed — enumeration comes from commit-record relcache invals)
NamespaceMapping (src/ch_emitter.rs) carries
auto_create, target_database, and drop_table_strategy (the latter two
resolved per-namespace in DdlApplicator); type_overrides,
order_by_default, and engine_default are not covered. The
watch::Receiver<Arc<ResolvedConfig>> resolver substrate
(config.md) merges CLI > PG-row > TOML with SIGHUP republish, and
the DdlApplicator refreshes namespace config from it per apply. The decode pool
reads Arc<RwLock<HashMap>> on the hot path, bridged from the watch snapshot by
a refresher task. The source-PG-driven work (signal channel, per-table opt-in +
backfill, net-new knobs) is
future/runtime_config_from_pg.md
- shadow vacuums by replay, never locally. Shadow runs
continuously in recovery with
autovacuum = off; any local catalog write would diverge from source's offset-exact pages & PANIC on next replay (promote-vacuum-reattach is equally unsound: timeline bump, no rewind path against synthetic walsender). Vacuum still happens: filter keeps every catalog-touching prune/vacuum/freeze/index-cleanup record (filter.md keep table), so source autovacuum on system catalogs replays & reclaims same bytes on shadow's mirror pages. ManualVACUUM FULL/REINDEXon source catalogs replay too, filenode rotation ridesRM_RELMAP_ID+pg_classheap writes. Steady-state shadow catalog bloat = source catalog bloat + replay lag - wal_level = logical required on source. Shadow needs full
old-tuple bytes on user-heap UPDATE/DELETE to drive
XLH_UPDATE_CONTAINS_OLD_KEYdecode;wal_level = replicainsufficient. Shadow itself runs atwal_level = replicabecause it never emits logical decoding - daemon supervises process. With
--bootstrap-shadow-data-dirset the daemon starts, probes, and restarts postmaster with capped backoff, then stops it on exit. Initialized standby always resumes; daemon never replaces it with a new base backup.ShadowCatalogreconnects after each restart. Without flag, shadow runs as external process such as k8s sidecar, and another supervisor owns it - PG version skew on cross-WAL replay. Shadow's PG version must match (or exceed in compatible ways) source's. See PG 17 repro docker memory note for PG-17-specific repro layout
- WAL struct alignment in body walker. Body block-id sentinels
255/254/253/252 must all be handled; missing 252 manifests as
BadBlockIdafter SAVEPOINT writes. See wal-rus block-id sentinels memory note - cross-segment user-heap records. Spanning records must NOOP-rewrite in both segments; otherwise shadow PG PANICs on missing pages. See cross-segment record memory note
- bootstrap.md — initdb vs
BASE_BACKUP,apply_schema_dumpconsumer,seed_from_sourcebootstrap fan-out - decoder.md — descriptor-log consumer, heap-tuple decode
against
RelDescriptor - emitter.md —
SchemaEventchannel consumer (ch_ddl::DdlApplicator), barrier-fence ordering - oracle.md — bridge worker behind the overlay read: socket,
framing, and the
SnapshotAnyprojectionsSCANreturns - source.md — walsender walshadow consumes from source; symmetry with walsender walshadow exposes to shadow
- future/risks.md — coarse-fire generation
invalidation, deferred
rfn-may-be-stalefast-path predicate - future/runtime_config_from_pg.md
—
ResolvedConfig+watchrefactor sequencing