src/oracle.rs plus pgext/
Tier 3 types are where in-tree decoders diverge from PG on edge cases:
on-disk varlena layouts shift between PG versions, typoutput
formatting carries locale baggage, custom typmod paths exist walshadow
doesn't reimplement. Ship known-stable types in-tree, route everything
else through the shadow-PG bridge calling the same typoutput PG itself
would call
Resolution is best-effort by policy: the oracle resolves post-plan, so
its answer reflects shadow's catalog state at resolve time, which may
lag the row's own catalog interval in DDL edge cases — accepted in
exchange for mostly supporting unknown types. Unresolved PgPending
ships raw on-disk bytes; unresolved Unsupported stays the
fail-closed backstop at encode
numeric, inet, cidr, interval. Decoded by
src/codecs.rs; see also decoder.md
for PgPending dispatch around them
Why these:
- stable wire format across PG versions walshadow targets
- mechanical conversion (no per-row libpq round-trip needed)
- locale-independent text rendering once
lc_numericis pinned
numeric carries NaN / ±Infinity sentinels; inet vs cidr
disambiguation lives at type-OID level not body bytes (on-disk vs wire
confusion surfaced here historically)
jsonb, arrays, tsvector, ranges, domains. Heap decoder emits
ColumnValue::PgPending { type_oid, raw };
resolve_pending_tuple collects every pending column of a
tuple into one DECODE request to shadow's bridge worker, swaps PgPending
for Text on each item that rendered
Two alternatives considered (insert + select round-trip;
SELECT $1::bytea::<typ>::text) require reconstructing wire format from
on-disk format — same codec work the worker elides
Lives at pgext/, built via PGXS. Not an extension: no control
file, no SQL script, no pg_proc row. Shadow's catalog is a read-only physical
copy of source's, so CREATE EXTENSION can never run there; the module is
reached only through shared_preload_libraries. Module is required on every
catalog shadow; walshadow writes preload settings into shadows it owns. See
pgext/walshadow.h for socket protocol definitions
ws_decode_datum_text reconstructs a Datum from on-disk bytes per typlen /
typbyval, then runs OidOutputFunctionCall on the type's typoutput. Four
branches: varlena / cstring / typbyval fixed / fixed by-ref
Files:
pgext/decode.c— on-disk Datum →typoutputtextpgext/overlay.c—SnapshotAnycatalog projectionspgext/worker.c— worker registration, socket, framingpgext/Makefile— PGXS-driven,MODULE_bigonly
Loaded into shadow PG; stays shadow-only.
typoutput is not a pure function of the bytes: timestamptz follows
TimeZone, dates DateStyle, interval IntervalStyle, bytea
bytea_output, floats extra_float_digits. The worker's connection would
otherwise inherit whatever database and role defaults replicated from source,
so it pins UTC / ISO, MDY / postgres / 1 / hex at startup under
PGC_S_OVERRIDE, which no reload or replicated ALTER DATABASE ... SET can
shift. Canonical output is what makes ClickHouse input deterministic; matching
the source session instead would need metadata WAL does not carry
DECODE is one of four ops the socket carries; SCAN is the other one with a
daemon consumer. It projects pg_class, pg_attribute, pg_index,
pg_namespace and pg_type under SnapshotAny, filtered to what one
transaction sees: rows it inserted are present, rows it deleted are not, and
another transaction's uncommitted rows are not. Values are each type's text
output form; ShadowCatalog assembles them into descriptors
(shadow.md)
Two arguments turn the same scan into the committed read the daemon captures at
a catalog commit. A top xid of 0 owns no transaction, so every in-progress
writer is foreign and the predicate degenerates to what an MVCC snapshot would
see. An empty oid list reads the whole catalog, which is the only mode
pg_namespace and pg_type ever had. One scan, so committed and uncommitted
descriptors cannot be built from different rules
The worker never opens the target relation — the replaying transaction holds
AccessExclusiveLock on it and relation_open would block against recovery.
Catalog table_open plus systable_beginscan is the whole surface, and
standby lock replay records AccessExclusiveLocks alone, so AccessShareLock on a
catalog cannot conflict with the DDL in flight. walshadow.lock_timeout_ms
bounds the one shape that argument misses: a replaying transaction holding
AccessExclusiveLock on a catalog itself
Which xids count as the requested transaction's is the hard part. Standby
pg_subtrans is only as complete as the XLOG_XACT_ASSIGNMENT records that
reached it, emitted past 64 cached subxids, so an ordinary savepoint leaves a
subxact with no recorded parent — indistinguishable from a foreign top-level
writer. A chain rooting elsewhere is proof of foreign; no recorded parent is
proof of nothing. Relation-scoped scans take the oid list as the lock argument
and trust an unresolvable writer as theirs, counting it in
subtrans_mismatch. Whole-catalog scans have no such argument and fail the
request as inconclusive, so an ok status always means every row returned is
bound to the requested transaction. Losing the oid list loses the lock argument
with it, which is why an empty list is only safe on the committed read — there
is no tree to misattribute a writer to
Daemon requires worker at startup. --bridge-socket defaults to
<shadow-socket-dir>/walshadow-bridge.sock; failure to connect within shadow
connect budget aborts startup. A worker whose typoutput raises for one item
bumps stats.fallback_raw and leaves that column raw, rest of batch stays
unaffected. Transport failure bumps stats.errors and client redials on next
request. Stats surface as
walshadow_decode_{resolved,fallback_raw,errors}_total
lc_numeric and lc_time pinned at shadow bootstrap. Without this,
typoutput on numeric and interval would diff against walshadow's
locale-independent rendering on deployments running non-C locales.
See shadow.md for bootstrap surface
- decoder.md —
ColumnValue::PgPendingdispatch + Tier 3 routing throughheap_decoder - shadow.md — where the worker's GUCs get written, lc_* pinning