Shadow PG already holds a live, WAL-replayed mirror of source's
catalog (pg_class, pg_attribute, pg_type, pg_index,
pg_constraint, pg_depend, pg_namespace, pg_proc for
defaults) with no user data. Today nothing consumes that beyond
ShadowCatalog's runtime lookups. Ship shadow's schema as a payload
delivered to a third-party PG cluster in one of two forms: A1 SQL
dump via pg_dump --schema-only against shadow's socket; A2 hollow
data directory of empty heaps + indexes, tar'd from shadow's data
dir after a prune pass
Third-party Postgres cluster wants source's schema without source itself. Three plausible consumers:
- Dev / test cluster bootstrap. Operator wants "source's schema
as of LSN X" loaded onto empty PG, no data. Today they run
pg_dump --schema-onlyagainst source, which costs source CPU and IO and requires production credentials. Shadow already has it, costs zero on source, and pins to a specific LSN rather than "whenever pg_dump's snapshot fired" - Bootstrap material for a fresh full-PG replica. Pairs with sync_commit_witness — when the surviving full-PG node needs re-seeding from scratch (or prior full-PG standby is destroyed), shadow's schema is the obvious starting point ahead of a data-bearing BASE_BACKUP
- CH schema sync. The emitter maps PG relations to CH tables via
TOML config today. A future "auto-create CH tables"
mode could ride shadow's catalog: new relation on source → shadow
sees catalog row → walshadow synthesises matching CH
CREATE TABLEbefore first data block lands
Walk shadow's catalog, emit DDL text. Closest analogue is pg_dump --schema-only. Shadow runs same PG major as source, so feeding
shadow's output back into psql on a matching-major target is
supported by PG itself
Lift: shadow can already produce this directly. pg_dump --schema-only -h <shadow-socket> -d <shadow-db> works today because
shadow holds catalog rows post-replay. Walshadow-side work is
wrapping the invocation: Shadow::dump_schema(target_lsn) -> String
gates on wait_for_replay(target_lsn) and shells out to pg_dump.
~50 LOC plus gating logic shadow already has
Emitted shapes:
CREATE SCHEMAfor every namespace except system schemasCREATE TYPE ... AS ENUM/ composite typesCREATE SEQUENCEwithlast_value/is_calledfrompg_sequenceCREATE TABLEwith column list (pg_attributeordered byattnum, skipping dropped),NOT NULL(attnotnull), defaults (pg_attrdef), check constraints (pg_constraint contype = 'c'), storage clausesALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY / UNIQUE / FOREIGN KEYforcontype IN ('p','u','f'), ordered topologically onpg_constraint.confrelidso referenced tables exist firstCREATE INDEX ... USING <am> (...) WHERE ...forpg_indexrows not already constraint-backedCREATE TRIGGER/CREATE RULE/GRANTonly if explicitly in scope; default is "out of scope, document as such"
Wins over pg_dump against source:
- Zero load on source PG. Catalog reads against shadow's socket on walshadow host
- LSN-scoped: dump reflects source's schema at exactly
target_lsn, useful for reproducing a debug state - Doesn't require production credentials on the dumping host
Caveats:
pg_dumpoutput assumes target can run itsSELECT pg_catalog.set_config(...)preamble; same constraint as upstream- Trigger functions live in
pg_proc.prosrc. Walshadow's filter classifies this as catalog (good) but function bodies referencing other schemas might fail to compile on a target that doesn't have those schemas yet. Standardpg_dumpordering problem, not walshadow's to solve - Extension state.
pg_extensionis catalog butCREATE EXTENSION fooruns the extension's install script which is not in shadow's catalog. Shadow knows "extension foo is installed at version X"; target must have foo's package installed for dump to apply
Same end state expressed differently. Walshadow ships a fully
initdb'd, catalog-populated, schema-aware data directory with
every user heap / index file present but zero-row. Effectively
shadow's own data dir, post-prune of any relfilenodes recovery
happened to touch with WAL
Shape:
- Spin shadow in normal (non-recovery) mode briefly
- Per
pg_class: anyoid >= FirstNormalObjectIdnon-catalog relfilenode → truncate to one empty page (or unlink; recovery doesn't touch these because filter NOOPs their WAL) - Tag directory with
.walshadow-schema-onlysentinel pg_ctl stop, tar the data dir
Target operator: tar -xf schema.tar -C /var/lib/postgresql/data && pg_ctl start. PG comes up with empty tables matching source's
schema. No psql replay, no DDL ordering surprises, indexes already
present (and valid, because empty)
Wins over A1:
- No DDL replay step on target. Fastest "schema present, ready for inserts" path
- Sequences carry
last_valuebyte-exact (sequence state lives in heap file, not just inpg_sequence) - Extension on-disk state (function OIDs, type OIDs in
pg_proc/pg_type) is byte-exact; subsequent WAL or COPY loads against target line up without OID-remap surprises
Caveats:
- Major-version-locked. A1's SQL survives minor-version drift on
target; A2's data dir doesn't (
pg_controlis major-pinned) - Storage on shadow during prune pass. Schema-only shadow is
MiB-scale, but prune sequence needs shadow to spin in read-only
mode first, which means recovery must catch up to
target_lsnbefore prune. Acceptable in steady state - OID skew on target. Source's
pg_class.oidpropagates exactly (the win), but target that's already beeninitdb'd with conflicting OIDs can't re-use the same data dir. Operator workflow is "fresh empty volume + shadow's tar"
A1 lands first as a 1-command shim around pg_dump. A2 is the
natural follow-up once a heap-prune pass on the shadow data dir
exists (today shadow holds catalog only; an explicit prune pass
isolates the catalog tablespace from any future heap-load
artefact). A2 then reduces to "skip enable_standby_recovery, tar
what's on disk"
No downstream consumer has asked. Speculative based on capabilities
walshadow already half-has, not on a deployment that's blocked
without it. CH schema sync is the most concrete prospect but the
emitter's TOML mapping covers the production path today. Dev/test bootstrap
is real but pg_dump --schema-only against source is already in
operators' muscle memory; the cost of running it on source instead
of shadow is "some CPU and IO" not "blocked workflow". Sync-commit
witness use case (re-seeding a full-PG replica from shadow) is
itself deferred (see sync_commit_witness.md); promoting both
ideas together is a natural pairing if either materialises
Neither A1 nor A2 blocks any existing surface. Land when a consumer with a specific use case forces the matrix
- A1: only needs
wait_for_replay(target_lsn)(shadow already has this) and a shell-out wrapper. Independent. - A2: requires a heap-prune pass on the shadow data dir to land. A2's output shape is "BASE_BACKUP shadow data dir, pruned, without enabling standby recovery"
Incremental schema delivery for catalog evolution after first
export. A1's pg_dump output is a one-shot snapshot at target_lsn;
subsequent source-side DDL doesn't propagate to the target consumer
automatically. Two shapes possible:
- Periodic re-dump. Operator schedules
dump_schema(now_lsn)on a cadence, target consumer runs the diff or full-replaces. Simplest; target burns CPU on each apply - Streaming DDL events. Walshadow already has the
SchemaEventchannel for CH applicator. A future "DDL relay to third-party PG" sink would consume the same channel and emit DDL text to a remote target as events arrive. Lighter on the target but requires a sink with its own connection management and failure semantics. Out of scope until a consumer asks
The sourcing decision (per-relation stream vs whole-payload) ties into which use case promotes the work. CH auto-create wants per-relation; cluster bootstrap wants whole-payload. Don't pre-judge which lands first — let the asking consumer choose
- A1 drill: daemon running with shadow caught up to LSN X. Issue
Shadow::dump_schema(X); output replays cleanly into a fresh matching-major PG viapsql. Schemas, sequences (withlast_value), tables, indexes, FKs all present and queryable. Zero impact on source PG (source connection count unchanged during dump) - A2 drill: shadow primed via the heap-prune pass.
Tar shadow's data dir, untar onto fresh volume on third-party
host,
pg_ctl start. PG starts cleanly, all user tables present and empty, indexes valid, sequences carry source'slast_value. Insert into a sequence-backed table assigns the next expected value - Out-of-scope items (triggers, ACLs, extensions requiring source-
side install scripts) documented as operator preconditions in
the export tool's
--help