diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 364476e..027c505 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,9 +138,10 @@ jobs: fixtures/wal/xlog_switch/capture.sh fixtures/wal/vacuum_full_pg_depend/capture.sh - # Install the walshadow extension before the e2e suite so each shadow PG - # can CREATE EXTENSION walshadow for the oracle decode path. - - name: Build + install walshadow extension (e2e oracle path) + # Build walshadow.so before the e2e suite: the bridge tests point + # dynamic_library_path at the build tree, so `make` alone is what they + # need. `make install` additionally proves the target docker relies on. + - name: Build + install walshadow PG module (e2e oracle path) working-directory: walshadow/pgext run: | PG_CONFIG=/usr/lib/postgresql/${{ matrix.pg-major }}/bin/pg_config @@ -184,35 +185,6 @@ jobs: if-no-files-found: ignore retention-days: 7 - # walshadow extension is a separate PGXS module (shipped to shadow PG). - # Build it against the matrix PG, install into that major's $libdir / - # share/extension, then run pg_regress under a private --temp-instance - # so we don't need an external server. Covers the C bridge function's - # varlena, fixed-width by-val, by-ref, NULL, and ereport paths. - - name: walshadow extension (build + pg_regress) - working-directory: walshadow/pgext - run: | - PG_BIN=/usr/lib/postgresql/${{ matrix.pg-major }}/bin - PG_CONFIG=$PG_BIN/pg_config - make PG_CONFIG=$PG_CONFIG - sudo make PG_CONFIG=$PG_CONFIG install - PG_REGRESS=/usr/lib/postgresql/${{ matrix.pg-major }}/lib/pgxs/src/test/regress/pg_regress - rm -rf /tmp/walshadow_pgext_tmpinst - "$PG_REGRESS" \ - --inputdir=./ --outputdir=./ \ - --bindir="$PG_BIN" \ - --temp-instance=/tmp/walshadow_pgext_tmpinst \ - --port=54329 \ - walshadow - - - name: walshadow extension regression diffs on failure - if: failure() - working-directory: walshadow/pgext - run: | - test -f regression.diffs && cat regression.diffs || true - test -d /tmp/walshadow_pgext_tmpinst/log && \ - sudo tail -n 200 /tmp/walshadow_pgext_tmpinst/log/postmaster.log || true - - name: ClickHouse server logs on failure if: failure() run: sudo tail -n 500 /var/log/clickhouse-server/clickhouse-server.err.log || true diff --git a/README.md b/README.md index bcd8932..294fbab 100644 --- a/README.md +++ b/README.md @@ -66,16 +66,19 @@ Binaries land under `target/release/`: - `walshadow-filter`, segment-level filter for offline WAL files - `walshadow-classify`, record-level classifier for diagnostics -The PG extension under `pgext/` is built separately via PGXS, only -needed for the decode oracle: +The PG module under `pgext/` is built separately via PGXS. It backs the +decode oracle and the catalog overlay reads: ``` make -C pgext install ``` -Loaded on shadow PG with `CREATE EXTENSION walshadow`. Absent extension -surfaces as `oracle fallback=N` on the status line; the daemon ships -raw on-disk bytes for `PgPending` types without it +Not an SQL extension: shadow's catalog is a read-only physical copy of +source's, so there is nowhere to run `CREATE EXTENSION`. Module must be +installed in shadow PG and loaded through `shared_preload_libraries`. Daemon +writes preload and `walshadow.*` settings for shadows it owns, then requires +worker socket. `--bridge-socket` defaults to +`/walshadow-bridge.sock` ## Running standalone @@ -128,12 +131,15 @@ params stay boot-only ## Testing ``` +make -C pgext cargo test cargo clippy --all-targets -- -D warnings ``` Integration tests under `tests/` need `initdb` + `pg_ctl` on `PATH` -and spin a transient shadow PG per case. Walshadow-side timeouts are +and spin a transient shadow PG per case. Every shadow preloads the +module out of `pgext/`, so build it first: tests fail rather than skip +without it. Walshadow-side timeouts are seconds-scale by design — long timeouts mask stalls rather than surface them @@ -143,7 +149,7 @@ surface them src/ walshadow daemon + library src/bin/ CLI entry points (stream, filter, classify) clickhouse-c-rs/ CH-Native client, separate submodule -pgext/ walshadow decode-bridge extension (PGXS) +pgext/ walshadow decode-bridge PG module (PGXS) architecture/ overview + internals diagrams plans/ component design docs (overview.md is the baseline) docker/ docker-compose demo + Dockerfile diff --git a/architecture/oracle.dot b/architecture/oracle.dot index 3b09554..bdfa90a 100644 --- a/architecture/oracle.dot +++ b/architecture/oracle.dot @@ -1,20 +1,19 @@ -// walshadow — PgPending decode oracle -// PgPending resolver path fronted by libpq into shadow PG. Extension -// surface (pgext/) shown as a side branch with absence-fallback -// semantics. +// walshadow, PgPending decode oracle +// PgPending resolver path over a unix socket into shadow PG's preloaded +// worker. Per-item and transport fallback stay visible. // // regeneration spec: // sources of truth: plans/oracle.md · pgext/ · src/ oracle-path module (grep src/ for oracle|pending) -// subsumes: plans/oracle.md § resolver flow + extension surface + absence semantics +// subsumes: plans/oracle.md § resolver flow + worker surface + failure semantics // quality bar: -// - two-way branch on extension availability legible (not a tangle) -// - libpq round-trip reads as one canonical orange edge in/out of shadow +// - required bridge path stays legible +// - batched socket round-trip reads as one canonical orange edge // - fallback path visually distinct from main path // shared style: palette.md digraph oracle { rankdir=TB; compound=true; - graph [fontname="Helvetica", labelloc="t", label="walshadow oracle — PgPending decode bridge", fontsize=14, splines=spline, nodesep=0.35, ranksep=0.9, bgcolor="#272623", fontcolor="#ECE1D7"]; + graph [fontname="Helvetica", labelloc="t", label="walshadow oracle, PgPending decode bridge", fontsize=14, splines=spline, nodesep=0.35, ranksep=0.9, bgcolor="#272623", fontcolor="#ECE1D7"]; node [fontname="Helvetica", fontsize=10, shape=box, style="rounded,filled", color="#6E6963", fontcolor="#ECE1D7"]; edge [fontname="Helvetica", fontsize=9, color="#c1a78e", fontcolor="#ECE1D7"]; @@ -29,26 +28,26 @@ digraph oracle { // ════════ ② oracle decode path ════════ subgraph cluster_oracle { - label="② oracle path — post-plan, decode workers call directly; metrics-only runs skip it"; style="rounded,filled"; color="#4c4641"; fillcolor="#34302c"; fontcolor="#ECE1D7"; - resolve [label="resolve_pending_tuple\nwalk columns,\nPgPending | Unsupported\n→ resolve_pending\nswap to Text on Ok(Some)\nbest effort: shadow may lag\nrow's catalog interval", fillcolor="#5D3F40"]; - stats [label="OracleStats\nresolved / fallback_raw /\nerrors", fillcolor="#5D3F40", shape=note]; + label="② oracle path, post-plan; metrics-only runs skip it"; style="rounded,filled"; color="#4c4641"; fillcolor="#34302c"; fontcolor="#ECE1D7"; + resolve [label="resolve_pending_tuple\ncollect PgPending | Unsupported\none DECODE batch per tuple\nswap each text answer to Text\nbest effort: shadow may lag\nrow's catalog interval", fillcolor="#5D3F40"]; + stats [label="OracleStats\nresolved / fallback_raw / errors\n\nBridgeStats\nup / requests / errors /\nlatency / reconnects / items", fillcolor="#5D3F40", shape=note]; } - // ════════ ③ libpq client (own pool, not catalog's) ════════ - client [label="Oracle::client\ntokio_postgres::Client\nSELECT walshadow_decode_disk(\n $1::oid, $2::bytea)\nreconnect-once on is_closed", fillcolor="#4D4D28"]; + // ════════ ③ bridge client ════════ + client [label="Bridge\ntokio::net::UnixStream\nHELLO version gate\nframed DECODE request\nreconnect + retry on\ntransport failure", fillcolor="#4D4D28"]; - // ════════ ④ shadow PG + extension ════════ + // ════════ ④ shadow PG + preloaded worker ════════ subgraph cluster_shadow { label="④ shadow PG"; style="rounded,filled"; color="#4c4641"; fillcolor="#34302c"; fontcolor="#ECE1D7"; - ext [label="walshadow extension\n(pgext/walshadow.c)\nSearchSysCache1(TYPEOID)\nDatum reconstruct ×4 branches:\n typlen=−1 varlena\n typlen=−2 cstring\n typbyval fixed (memcpy)\n fixed by-ref (palloc)\nOidOutputFunctionCall(typoutput)", fillcolor="#3D4128"]; - probe [label="probe_extension\nSELECT EXISTS\n(pg_proc.proname =\n 'walshadow_decode_disk')\nat connect + reconnect", fillcolor="#3D4128", shape=note]; - lc [label="pinned locale\nlc_numeric / lc_time = C\n(shadow bootstrap)", fillcolor="#3D4128", shape=note]; + worker [label="walshadow background worker\n(pgext/worker.c)\nunix socket, mode 0600\none transaction per request\none subtransaction per item\nerror frame or connection close", fillcolor="#3D4128"]; + decode [label="ws_decode_datum_text\n(pgext/decode.c)\nSearchSysCache1(TYPEOID)\nDatum reconstruct ×4 branches:\n typlen=−1 varlena\n typlen=−2 cstring\n typbyval fixed (memcpy)\n fixed by-ref (palloc)\nOidOutputFunctionCall(typoutput)", fillcolor="#3D4128"]; + env [label="pinned output environment\nTimeZone = UTC\nDateStyle = ISO, MDY\nIntervalStyle = postgres\nextra_float_digits = 1\nbytea_output = hex", fillcolor="#3D4128", shape=note]; } // ════════ ⑤ pgext build artifact ════════ subgraph cluster_pgext { label="⑤ pgext/ (PGXS build)"; style="rounded,filled"; color="#4c4641"; fillcolor="#34302c"; fontcolor="#ECE1D7"; - artifact [label="walshadow.so\nwalshadow.control\nwalshadow--0.1.sql\nCREATE FUNCTION\n STRICT IMMUTABLE", fillcolor="#4D3850", shape=note]; + artifact [label="walshadow.so\ndecode.o + overlay.o + worker.o\nno control file\nno SQL script\nno pg_proc row", fillcolor="#4D3850", shape=note]; } // ════════ ⑥ emit sinks (downstream branch off oracle) ════════ @@ -60,29 +59,30 @@ digraph oracle { } // ─── main flow edges (LR spine, high weight) ─── - pend -> resolve [color="#A1A9CC", penwidth=2, label="always", weight=10]; - unsup -> resolve [color="#A1A9CC", style=dashed, label="always", weight=5]; - resolve -> client [color="#CBA85E", penwidth=2, label="resolve_pending\nper pending col", weight=10]; - client -> ext [color="#CBA85E", penwidth=2, dir=both, arrowtail=open, lhead=cluster_shadow, label="libpq SELECT\ntokio_postgres pool\n(separate from\n ShadowCatalog)", weight=10]; + pend -> resolve [color="#A1A9CC", penwidth=2, label="required bridge", weight=10]; + unsup -> resolve [color="#A1A9CC", style=dashed, label="required bridge", weight=5]; + resolve -> client [color="#CBA85E", penwidth=2, label="one DECODE batch\nper tuple", weight=10]; + client -> worker [color="#CBA85E", penwidth=2, dir=both, arrowtail=open, lhead=cluster_shadow, label="framed unix\nsocket request", weight=10]; // ─── local tiers never reach the oracle ─── tier12 -> emit_text [color="#BF8C5F", style=dotted, constraint=false, label="local render"]; tier3 -> emit_text [color="#BF8C5F", style=dotted, constraint=false, label="local render"]; - // ─── shadow internal: extension uses pinned locale via typoutput ─── - ext -> lc [style=dashed, color="#6E6963", constraint=false]; - client -> probe [style=dashed, color="#CBA85E", constraint=false, label="connect /\nreconnect:\nprobe pg_proc"]; + // ─── shadow internal: worker calls decode under pinned output GUCs ─── + worker -> decode [color="#6E6963", label="each item"]; + decode -> env [style=dashed, color="#6E6963", constraint=false]; - // ─── pgext install edge ─── - artifact -> ext [style=dashed, color="#6E6963", lhead=cluster_shadow, ltail=cluster_pgext, label="install:\n$libdir +\nextension dir;\nCREATE EXTENSION\nwalshadow"]; + // ─── pgext preload edge ─── + artifact -> worker [style=dashed, color="#6E6963", lhead=cluster_shadow, ltail=cluster_pgext, label="$libdir or build tree\nshared_preload_libraries\n+ walshadow.* GUCs"]; // ─── stats fan-in (off-axis, constraint=false) ─── resolve -> stats [style=dashed, color="#CBA85E", constraint=false, label="resolved++ /\nfallback_raw++ /\nerrors++"]; + client -> stats [style=dashed, color="#CBA85E", constraint=false, label="request /\ntransport counters"]; // ─── emit disposition (off-axis fan-out from oracle) ─── - resolve -> emit_text [color="#BF8C5F", penwidth=2, constraint=false, label="Ok(Some)\npending\n→ Text"]; - resolve -> emit_raw [color="#BF8C5F", style=dashed, constraint=false, label="Ok(None)\next absent /\nNULL / error\n(PgPending)"]; - resolve -> emit_fail [color="#BF8C5F", style=dotted, constraint=false, label="Ok(None)\n(Unsupported)"]; + resolve -> emit_text [color="#BF8C5F", penwidth=2, constraint=false, label="text item\n→ Text"]; + resolve -> emit_raw [color="#BF8C5F", style=dashed, constraint=false, label="item or transport error\n(PgPending)"]; + resolve -> emit_fail [color="#BF8C5F", style=dotted, constraint=false, label="item or transport error\n(Unsupported)"]; // ─── Legend ─── legend [shape=plaintext, label=< @@ -90,20 +90,20 @@ digraph oracle { node fill decoder ColumnValue oracle (src/ops/oracle.rs) - libpq client (own pool) - shadow PG / extension + bridge client + shadow PG worker pgext/ build artifact emit sink edge colour - ━━pending → resolver (always) - ━━libpq SELECT (MAIN wire) + ━━pending → resolver + ━━unix socket (MAIN wire) ━━emit-time disposition - ┄┄pgext install (filesystem) + ┄┄pgext preload (filesystem) policy Best effort: resolution runs post-plan;
shadow's catalog may lag the row's
interval in DDL edge cases — accepted
to mostly support unknown types. - extension-absence - probe_extension at connect →
has_extension=false →
resolve_pending Ok(None) →
stats.fallback_raw++ →
emitter writes raw bytes.
No failure. CREATE EXTENSION
+ reconnect re-enables. + bridge absence + Unset socket or boot connect failure
means no Oracle. PgPending reaches
emitter unchanged and writes raw
bytes. Unsupported remains fatal.
Runtime transport failure retries
once, then uses same disposition. >]; - ext -> legend [style=invis]; + worker -> legend [style=invis]; } diff --git a/architecture/oracle.svg b/architecture/oracle.svg index 85ce3a5..fb604a8 100644 --- a/architecture/oracle.svg +++ b/architecture/oracle.svg @@ -4,359 +4,361 @@ - - + + oracle - -walshadow oracle — PgPending decode bridge + +walshadow oracle, PgPending decode bridge cluster_decoder - -① decoder — ColumnValue (heap_decoder.rs) + +① decoder — ColumnValue (heap_decoder.rs) cluster_oracle - -② oracle path — post-plan, decode workers call directly; metrics-only runs skip it + +② oracle path, post-plan; metrics-only runs skip it cluster_shadow - -④ shadow PG + +④ shadow PG cluster_pgext - -⑤ pgext/ (PGXS build) + +⑤ pgext/ (PGXS build) cluster_emit - -⑥ downstream — emit-time disposition + +⑥ downstream — emit-time disposition tier12 - -ColumnValue::Tier 1/2 -fixed-width / varlena -locally rendered -(no raw bytes kept) + +ColumnValue::Tier 1/2 +fixed-width / varlena +locally rendered +(no raw bytes kept) emit_text - -ColumnValue::Text -→ CH String -(encode_value) + +ColumnValue::Text +→ CH String +(encode_value) tier12->emit_text - - -local render + + +local render tier3 - -ColumnValue::Tier 3 -numeric / inet / cidr / -interval (codecs.rs) -local decode only -(raw bytes discarded) + +ColumnValue::Tier 3 +numeric / inet / cidr / +interval (codecs.rs) +local decode only +(raw bytes discarded) tier3->emit_text - - -local render + + +local render pend - -ColumnValue::PgPending -{ type_oid, raw } -jsonb / array / range / -tsvector / domain / vendor + +ColumnValue::PgPending +{ type_oid, raw } +jsonb / array / range / +tsvector / domain / vendor resolve - -resolve_pending_tuple -walk columns, -PgPending | Unsupported -→ resolve_pending -swap to Text on Ok(Some) -best effort: shadow may lag -row's catalog interval + +resolve_pending_tuple +collect PgPending | Unsupported +one DECODE batch per tuple +swap each text answer to Text +best effort: shadow may lag +row's catalog interval pend->resolve - - -always + + +required bridge unsup - -ColumnValue::Unsupported -{ type_oid, raw } -fail-closed backstop -unless oracle resolves + +ColumnValue::Unsupported +{ type_oid, raw } +fail-closed backstop +unless oracle resolves unsup->resolve - - -always + + +required bridge stats - - - -OracleStats -resolved / fallback_raw / -errors + + + +OracleStats +resolved / fallback_raw / errors +BridgeStats +up / requests / errors / +latency / reconnects / items resolve->stats - - -resolved++ / -fallback_raw++ / -errors++ + + +resolved++ / +fallback_raw++ / +errors++ client - -Oracle::client -tokio_postgres::Client -SELECT walshadow_decode_disk( -  $1::oid, $2::bytea) -reconnect-once on is_closed + +Bridge +tokio::net::UnixStream +HELLO version gate +framed DECODE request +reconnect + retry on +transport failure resolve->client - - -resolve_pending -per pending col + + +one DECODE batch +per tuple - + resolve->emit_text - - -Ok(Some) -pending -→ Text + + +text item +→ Text emit_raw - -PgPending raw bytes -→ append_string_bytes(raw) -best-effort verbatim body + +PgPending raw bytes +→ append_string_bytes(raw) +best-effort verbatim body - + resolve->emit_raw - - -Ok(None) -ext absent / -NULL / error -(PgPending) + + +item or transport error +(PgPending) emit_fail - - - -Unsupported unresolved -→ EmitterError:: -UnsupportedValue (fatal) + + + +Unsupported unresolved +→ EmitterError:: +UnsupportedValue (fatal) - + resolve->emit_fail - - -Ok(None) -(Unsupported) + + +item or transport error +(Unsupported) + + + +client->stats + + +request / +transport counters - + -ext - -walshadow extension -(pgext/walshadow.c) -SearchSysCache1(TYPEOID) -Datum reconstruct ×4 branches: -  typlen=−1 varlena -  typlen=−2 cstring -  typbyval fixed (memcpy) -  fixed by-ref (palloc) -OidOutputFunctionCall(typoutput) - - +worker + +walshadow background worker +(pgext/worker.c) +unix socket, mode 0600 +one transaction per request +one subtransaction per item +error frame or connection close + + -client->ext - - - -libpq SELECT -tokio_postgres pool -(separate from - ShadowCatalog) - - +client->worker + + + +framed unix +socket request + + -probe - - - -probe_extension -SELECT EXISTS -(pg_proc.proname = - 'walshadow_decode_disk') -at connect + reconnect - - - -client->probe - - -connect / -reconnect: -probe pg_proc - - - -lc - - - -pinned locale -lc_numeric / lc_time = C -(shadow bootstrap) - - +decode + +ws_decode_datum_text +(pgext/decode.c) +SearchSysCache1(TYPEOID) +Datum reconstruct ×4 branches: +  typlen=−1 varlena +  typlen=−2 cstring +  typbyval fixed (memcpy) +  fixed by-ref (palloc) +OidOutputFunctionCall(typoutput) + + -ext->lc - - +worker->decode + + +each item legend - - - -node fill - - - -decoder ColumnValue - - - -oracle (src/ops/oracle.rs) - - - -libpq client (own pool) - - - -shadow PG / extension - - - -pgext/ build artifact - - - -emit sink - - -edge colour - -━━ - -pending → resolver (always) - -━━ - -libpq SELECT (MAIN wire) - -━━ - -emit-time disposition - -┄┄ - -pgext install (filesystem) - - -policy - -Best effort: resolution runs post-plan; -shadow's catalog may lag the row's -interval in DDL edge cases — accepted -to mostly support unknown types. - - -extension-absence - -probe_extension at connect → -has_extension=false → -resolve_pending Ok(None) → -stats.fallback_raw++ → -emitter writes raw bytes. -No failure. CREATE EXTENSION -+ reconnect re-enables. - - + + + +node fill + + + +decoder ColumnValue + + + +oracle (src/ops/oracle.rs) + + + +bridge client + + + +shadow PG worker + + + +pgext/ build artifact + + + +emit sink + + +edge colour + +━━ + +pending → resolver + +━━ + +unix socket (MAIN wire) + +━━ + +emit-time disposition + +┄┄ + +pgext preload (filesystem) + + +policy + +Best effort: resolution runs post-plan; +shadow's catalog may lag the row's +interval in DDL edge cases — accepted +to mostly support unknown types. + + +bridge absence + +Unset socket or boot connect failure +means no Oracle. PgPending reaches +emitter unchanged and writes raw +bytes. Unsupported remains fatal. +Runtime transport failure retries +once, then uses same disposition. + + + + +env + + + +pinned output environment +TimeZone = UTC +DateStyle = ISO, MDY +IntervalStyle = postgres +extra_float_digits = 1 +bytea_output = hex + + + +decode->env + + + artifact - - - -walshadow.so -walshadow.control -walshadow--0.1.sql -CREATE FUNCTION -  STRICT IMMUTABLE - - + + + +walshadow.so +decode.o + overlay.o + worker.o +no control file +no SQL script +no pg_proc row + + -artifact->ext - - -install: -$libdir + -extension dir; -CREATE EXTENSION -walshadow +artifact->worker + + +$libdir or build tree +shared_preload_libraries ++ walshadow.* GUCs diff --git a/architecture/shadow.dot b/architecture/shadow.dot index 36aefc2..aa35d0a 100644 --- a/architecture/shadow.dot +++ b/architecture/shadow.dot @@ -1,120 +1,100 @@ -// walshadow — ShadowCatalog internal state + schema-event emission -// Internal-state companion to shadow_communication.dot (channels). Here: -// LRU shape + generation counter + invalidation-epoch fold + reconnect -// resilience (folded into libpq node) + xid-armed DROP sweep + -// SchemaEvent fan-out to DdlApplicator / XactBuffer. +// walshadow, ShadowCatalog descriptor assembly from bridge projections // // regeneration spec: -// sources of truth: plans/shadow.md · src/shadow_catalog.rs -// subsumes: plans/shadow.md § ShadowCatalog cache + invalidation + SchemaEvent emission -// differentiates: shadow_communication.dot draws the three CHANNELS (libpq, walsender, restore_command); this draws ShadowCatalog INTERNAL state — don't duplicate +// sources of truth: plans/shadow.md · src/catalog/shadow_catalog.rs · src/ops/bridge.rs · pgext/overlay.c +// subsumes: plans/shadow.md § ShadowCatalog + One descriptor definition + Uncommitted DDL + Reconnect resilience +// differentiates: shadow_communication.dot draws transport channels; this draws descriptor read and assembly paths // quality bar: -// - gen → LRU invalidation edge clearly dashed, doesn't fight populate edges -// - reconnect cluster off to the side, doesn't deform main flow -// - shadow PG cylinder appears once (libpq + apply_lsn gate both touch it) +// - committed and overlay bridge paths stay visible +// - replay movement fails closed +// - replay pin spans every worker scan and oid chunk // shared style: palette.md digraph shadow_catalog { rankdir=TB; compound=true; - graph [fontname="Helvetica", labelloc="t", label="ShadowCatalog — LRU + generation invalidation + SchemaEvent emission (plans/shadow.md)", fontsize=14, splines=spline, nodesep=0.4, ranksep=0.6, bgcolor="#272623", fontcolor="#ECE1D7"]; + graph [fontname="Helvetica", labelloc="t", label="ShadowCatalog, pinned catalog projections → one descriptor definition", fontsize=14, splines=spline, nodesep=0.45, ranksep=0.65, bgcolor="#272623", fontcolor="#ECE1D7"]; node [fontname="Helvetica", fontsize=10, shape=box, style="rounded,filled", color="#6E6963", fontcolor="#ECE1D7"]; edge [fontname="Helvetica", fontsize=9, color="#c1a78e", fontcolor="#ECE1D7"]; - // ──────── Top rank: external producer + consumer ──────── - { rank=same; - tracker [label="CatalogTracker\n(filter pipeline, sync)\ncatalog WAL writes →\nepoch bump (Release-store)\n+ CatalogSignal verdict on Record", fillcolor="#4D3A28"]; - caller [label="BufferingDecoderSink\nrelation_at(rfn, at_lsn)\n(decoder worker task)", fillcolor="#4D4128"]; - } - - // ──────── Shared cross-pipeline state ──────── - atomics [label="invalidation_epoch\nArc\n(any catalog WAL write)", fillcolor="#4D4D28", shape=ellipse]; - psweeps [label="PendingSweeps\nMutex>\narmed per pg_class heap_delete", fillcolor="#4D4D28", shape=ellipse]; - tracker -> atomics [label="bump at\npump position", style=dashed, color="#CBA85E"]; - tracker -> caller [label="CatalogSignal\nrides Record", style=dashed, color="#CBA85E"]; - caller -> atomics [label="re-bump at\nworker position", style=dashed, color="#CBA85E"]; - caller -> psweeps [label="arm(xid) on\nInvalidateSweep", style=dashed, color="#CBA85E"]; - - // ──────── ShadowCatalog cluster — full internal state ──────── - subgraph cluster_cat { - label="ShadowCatalog (src/shadow_catalog.rs)"; + subgraph cluster_catalog { + label="ShadowCatalog (src/catalog/shadow_catalog.rs)"; style="rounded,filled"; color="#4c4641"; fillcolor="#34302c"; fontcolor="#ECE1D7"; - gate [label="relation_at orchestration\n① drain_invalidations (Acquire epoch)\n② wait_for_replay(at_lsn)\n③ drain_invalidations\n④ cache lookup", fillcolor="#4D4D28"]; + { rank=same; + committed [label="committed APIs\nfetch_descriptors_batch(oids)\nfetch_all_descriptors()\ndescriptor_by_name / toast\nscope = oids | eligible", fillcolor="#4D4D28"]; + overlay [label="overlay API\nfetch_overlay_descriptors\noids + top_xid + boundary\nbridge required", fillcolor="#4D4D28"]; + replay [label="replay gate API\nwait_for_replay(target)\nmonotone last observed LSN\npoll until target or timeout", fillcolor="#4D4D28"]; + } - gencnt [label="generation: u64\nentry hit iff gen == self.gen\nbumped by:\n • epoch advance (drain)\n • reconnect\n • DROP sweep", fillcolor="#4D4D28", shape=ellipse]; + scan [label="scan_rows, worker source\ntop_xid = 0: committed view\ntop_xid ≠ 0: own-xid overlay\npg_class → pg_attribute → pg_index\nnamespace + type name maps", fillcolor="#4D4D28"]; - lru [label="by_filenode + by_oid\nHashMap + EvictionIndex (FIFO)\ncap = 4096", fillcolor="#4D4D28"]; + pin [label="scan_pinned invariant\nfirst SCAN fixes replay LSN\nor caller supplies boundary\nevery catalog + oid chunk\nmust start and end at same LSN", fillcolor="#4D4D28", shape=note]; - fetch [label="fetch_by_filenode / fetch_by_oid\nSELECT pg_class + pg_namespace +\npg_index (replident) +\npg_attribute + pg_type\n(one SQL fan-out per miss)", fillcolor="#4D4D28"]; + libpq [label="tokio-postgres client\nunix socket, long-lived\nensure_open + one reconnect retry\nreset last_replay_lsn on reconnect", fillcolor="#4D4D28"]; - libpq [label="tokio_postgres::Client\nunix socket, long-lived\nensure_open → with_transient_retry\n→ reconnect on is_closed:\n rebuild Client (conninfo stashed),\n bump gen, reset last_replay_lsn", fillcolor="#4D4D28"]; + rows [label="DescriptorRows\nclass / attrs / indexes\nnamespace + type maps\nreplay_lsn", fillcolor="#4D4D28", shape=folder]; - rec [label="record_descriptor\ndiff(new, prev_known[oid])\n→ Added | Changed{diff} |\nno-op (Arc::ptr_eq)", fillcolor="#4D4D28"]; + assemble [label="DescriptorRows::assemble\nreject duplicate oid or attnum\npreserve dropped attribute slots\nchoose PK + replica identity\nresolve reltablespace 0\nuse raw relfilenode", fillcolor="#4D4D28"]; - prev [label="prev_known\nHashMap>\nlast-seen shape per oid", fillcolor="#4D4D28", shape=folder]; + result [label="RelDescriptor\nrfn + oid + toast oid\nname + kind + persistence\nreplident + attributes\n\ncommitted: replay_lsn + Vec\noverlay: Vec", fillcolor="#4D4D28", shape=parallelogram]; - sweep [label="sweep_dropped\npoll pg_class for known oids;\nmissing → emit Dropped\nno internal throttle", fillcolor="#4D4D28"]; + committed -> scan [label="top_xid = 0", color="#CBA85E", penwidth=2]; + overlay -> scan [label="top_xid + boundary", color="#CBA85E", penwidth=2]; + replay -> libpq [label="pg_last_wal_replay_lsn()", color="#CBA85E"]; + scan -> pin [style=dashed, color="#B58B86", constraint=false]; + scan -> libpq [label="committed namespace/type names\nDB oid + default tablespace", color="#CBA85E", dir=both, arrowtail=open]; + scan -> rows [label="parsed SCAN rows", color="#CBA85E"]; + rows -> assemble [color="#CBA85E", penwidth=2]; + assemble -> result [color="#CBA85E", penwidth=2]; } - // ──────── Shadow PG (consolidated endpoint) ──────── - shd [label="shadow PG\npg_class / pg_attribute /\npg_type / pg_index /\npg_last_wal_replay_lsn()", fillcolor="#3D4128", shape=cylinder]; + bridge [label="Bridge\nframed unix socket\nSCAN request\nchunk at MAX_SCAN_OIDS", fillcolor="#4D4D28"]; + + subgraph cluster_shadow { + label="shadow PG"; + style="rounded,filled"; color="#4c4641"; fillcolor="#34302c"; fontcolor="#ECE1D7"; + + worker [label="walshadow worker\nSCAN under SnapshotAny\nown-xid visibility\nreplay LSN before + after", fillcolor="#3D4128"]; + + catalogs [label="pg_class / pg_attribute /\npg_index / pg_namespace /\npg_type / pg_database\n+ replay position", fillcolor="#3D4128", shape=cylinder]; - // ──────── SchemaEvent fan-out ──────── - evtx [label="schema_event_tx\nmpsc::unbounded\n(single subscriber)", fillcolor="#4D4D28", shape=parallelogram]; - { rank=same; - xbuf [label="XactBuffer\nstamps SchemaEvent on xid;\ndrain-time interleave with tuples", fillcolor="#4D4128"]; - ddlapp [label="DdlApplicator\nch_ddl.rs (own CH TCP)\ndrains at commit boundary", fillcolor="#5D4628"]; + worker -> catalogs [label="direct catalog rows", color="#6E6963", dir=both, arrowtail=open]; } - // ──────── Hot lookup path (solid) ──────── - caller -> gate [label="rfn, at_lsn", color="#CBA85E"]; - gate -> lru [label="check\n(gen, rfn|oid)", color="#CBA85E"]; - lru -> fetch [label="miss", style=dashed, color="#CBA85E"]; - fetch -> libpq; - fetch -> rec [label="Arc"]; - fetch -> lru [label="insert\nEvictionIndex\nevict over cap", style=dashed, color="#CBA85E"]; - rec -> prev [label="upsert", style=dashed, color="#CBA85E"]; - rec -> evtx [label="Added | Changed{diff}", color="#CBA85E", penwidth=2]; - - // ──────── libpq endpoint (only path to shadow PG) ──────── - libpq -> shd [label="SELECT pg_class / pg_attribute /\npg_type / pg_index +\npg_last_wal_replay_lsn()\nport=55434, unix sock", color="#CBA85E", dir=both, arrowtail=open, penwidth=2]; - - // ──────── Generation-bump feedback (dashed) ──────── - atomics -> gate [label="Acquire-load\nper lookup", style=dashed, color="#CBA85E"]; - gate -> gencnt [label="bump on advance", style=dashed, color="#CBA85E"]; - gencnt -> lru [label="gen mismatch ⇒ miss", style=dashed, color="#CBA85E"]; - - // ──────── DROP sweep path ──────── - psweeps -> sweep [label="disarm at armed xact's commit ⇒\nwait_for_replay(commit lsn), sweep\n(abort disarms, no sweep)", style=dashed, color="#CBA85E"]; - sweep -> libpq [label="SELECT oid = ANY($1)\nFROM pg_class", style=dashed, color="#CBA85E"]; - sweep -> prev [label="remove missing", style=dashed, color="#CBA85E"]; - sweep -> evtx [label="Dropped { oid, qname }", color="#CBA85E"]; - sweep -> gencnt [label="bump on DROP", style=dashed, color="#CBA85E"]; - - // ──────── Downstream emission ──────── - evtx -> xbuf [label="stamp on xid\n(lsn-ordered drain)", style=dashed, color="#CBA85E"]; - evtx -> ddlapp [label="drain at commit", color="#BF8C5F", penwidth=2]; - - // ──────── Legend ──────── + scan -> bridge [label="one SCAN per catalog / chunk", color="#CBA85E", penwidth=2, dir=both, arrowtail=open]; + bridge -> worker [label="worker protocol", color="#CBA85E", penwidth=2, dir=both, arrowtail=open, lhead=cluster_shadow]; + libpq -> catalogs [label="SQL + pg_last_wal_replay_lsn()", color="#CBA85E", penwidth=2, dir=both, arrowtail=open, lhead=cluster_shadow]; + + capture [label="CatalogCapture\nboundary hold requires\nreturned LSN == next_lsn\ndiff historical predecessor\nderive SchemaEvent", fillcolor="#4D4128"]; + + direct [label="direct consumers\nboot seed / opt-in dispatch /\nbackup TOAST lookup", fillcolor="#4D4128"]; + + log [label="DescriptorLog\nappend durable batch\nown descriptor history", fillcolor="#4D3850", shape=note]; + + xbuf [label="XactBuffer\nstamp SchemaEvent on xid\ncommit-order drain", fillcolor="#4D4128"]; + + result -> capture [label="boundary fetch", color="#CBA85E", penwidth=2]; + result -> direct [label="non-boundary fetch", color="#CBA85E"]; + capture -> log [label="batch", color="#b380b0", style=dotted, penwidth=2]; + capture -> xbuf [label="Added | Changed | Dropped", color="#b380b0", style=dotted]; + legend [shape=plaintext, label=< - - - - - - - - - - - - - - - - + + + + + + + + + + + + + +
node fill — role
ShadowCatalog internal (cache, gates, epochs)
decoder / XactBuffer (consumer)
CatalogTracker (filter, producer of bumps)
DdlApplicator (CH DDL, own TCP)
shadow Postgres
edge style
━━libpq query / SchemaEvent flow (solid = forward path)
┄┄invalidation / cache feedback (dashed)
━━CH Native DDL (off hot path, separate TCP)
SchemaEvent variants
Addedfirst sight of oid → CREATE TABLE (post-DROP re-create, seed_from_source)
Changedrefetched shape ≠ prev_known: ADD/DROP/RENAME COLUMN, type widen (rejected for now)
Droppedsweep at armed commit → configured dest action; pg_toast → durable deferred mirror retire
generation invariant
one DDL → one bump → every cache entry stale on next access. Coarse-fire over-invalidates (hint-bit / autovac noise count) but never under-invalidates — schema correctness is the floor, refetch cost the negotiable.
node fill, role
ShadowCatalog + bridge client
shadow PG worker + catalogs
capture and descriptor consumers
durable descriptor log
edge color
━━descriptor query + assembly path
┄┄replay-pin invariant
┈┈descriptor durability + schema-event handoff
source policy
committedworker only; ReplayMismatch fails read
overlayworker only; ReplayMismatch fails read
ownership
ShadowCatalog has no cache, invalidation state, or event channel. DescriptorLog owns history; CatalogCapture derives events.
>]; - ddlapp -> legend [style=invis]; + log -> legend [style=invis]; } diff --git a/architecture/shadow.svg b/architecture/shadow.svg index 000fd37..d86abc9 100644 --- a/architecture/shadow.svg +++ b/architecture/shadow.svg @@ -4,400 +4,346 @@ - - + + shadow_catalog - -ShadowCatalog — LRU + generation invalidation + SchemaEvent emission (plans/shadow.md) - -cluster_cat - -ShadowCatalog (src/shadow_catalog.rs) - - + +ShadowCatalog, pinned catalog projections → one descriptor definition + +cluster_catalog + +ShadowCatalog (src/catalog/shadow_catalog.rs) + + +cluster_shadow + +shadow PG + + -tracker - -CatalogTracker -(filter pipeline, sync) -catalog WAL writes → -epoch bump (Release-store) -+ CatalogSignal verdict on Record - - - -caller - -BufferingDecoderSink -relation_at(rfn, at_lsn) -(decoder worker task) +committed + +committed APIs +fetch_descriptors_batch(oids) +fetch_all_descriptors() +descriptor_by_name / toast +scope = oids | eligible + + + +scan + +scan_rows, worker source +top_xid = 0: committed view +top_xid ≠ 0: own-xid overlay +pg_class → pg_attribute → pg_index +namespace + type name maps + + + +committed->scan + + +top_xid = 0 - + + +overlay + +overlay API +fetch_overlay_descriptors +oids + top_xid + boundary +bridge required + + -tracker->caller - - -CatalogSignal -rides Record +overlay->scan + + +top_xid + boundary - + -atomics - -invalidation_epoch -Arc<AtomicU64> -(any catalog WAL write) +replay + +replay gate API +wait_for_replay(target) +monotone last observed LSN +poll until target or timeout - - -tracker->atomics - - -bump at -pump position + + +libpq + +tokio-postgres client +unix socket, long-lived +ensure_open + one reconnect retry +reset last_replay_lsn on reconnect - + -caller->atomics - - -re-bump at -worker position - - - -psweeps - -PendingSweeps -Mutex<HashSet<xid>> -armed per pg_class heap_delete +replay->libpq + + +pg_last_wal_replay_lsn() - + + +pin + + + +scan_pinned invariant +first SCAN fixes replay LSN +or caller supplies boundary +every catalog + oid chunk +must start and end at same LSN + + -caller->psweeps - - -arm(xid) on -InvalidateSweep +scan->pin + + - - -gate - -relation_at orchestration -① drain_invalidations (Acquire epoch) -② wait_for_replay(at_lsn) -③ drain_invalidations -④ cache lookup - - + -caller->gate - - -rfn, at_lsn - - - -atomics->gate - - -Acquire-load -per lookup - - - -sweep - -sweep_dropped -poll pg_class for known oids; -missing → emit Dropped -no internal throttle - - - -psweeps->sweep - - -disarm at armed xact's commit ⇒ -wait_for_replay(commit lsn), sweep -(abort disarms, no sweep) - - - -gencnt - -generation: u64 -entry hit iff gen == self.gen -bumped by: -  • epoch advance (drain) -  • reconnect -  • DROP sweep - - - -gate->gencnt - - -bump on advance - - +scan->libpq + + + +committed namespace/type names +DB oid + default tablespace + + -lru - -by_filenode + by_oid -HashMap + EvictionIndex (FIFO) -cap = 4096 - - +rows + +DescriptorRows +class / attrs / indexes +namespace + type maps +replay_lsn + + -gate->lru - - -check -(gen, rfn|oid) +scan->rows + + +parsed SCAN rows - - -gencnt->lru - - -gen mismatch ⇒ miss + + +bridge + +Bridge +framed unix socket +SCAN request +chunk at MAX_SCAN_OIDS + + + +scan->bridge + + + +one SCAN per catalog / chunk - + + +catalogs + + +pg_class / pg_attribute / +pg_index / pg_namespace / +pg_type / pg_database ++ replay position + + + +libpq->catalogs + + + +SQL + pg_last_wal_replay_lsn() + + -fetch - -fetch_by_filenode / fetch_by_oid -SELECT pg_class + pg_namespace + -pg_index (replident) + -pg_attribute + pg_type -(one SQL fan-out per miss) - - +assemble + +DescriptorRows::assemble +reject duplicate oid or attnum +preserve dropped attribute slots +choose PK + replica identity +resolve reltablespace 0 +use raw relfilenode + + -lru->fetch - - -miss - - - -fetch->lru - - -insert -EvictionIndex -evict over cap +rows->assemble + + - + -libpq - -tokio_postgres::Client -unix socket, long-lived -ensure_open → with_transient_retry -→ reconnect on is_closed: -  rebuild Client (conninfo stashed), -  bump gen, reset last_replay_lsn - - +result + +RelDescriptor +rfn + oid + toast oid +name + kind + persistence +replident + attributes +committed: replay_lsn + Vec +overlay: Vec + + -fetch->libpq - - - - - -rec - -record_descriptor -diff(new, prev_known[oid]) -→ Added | Changed{diff} | -no-op (Arc::ptr_eq) - - - -fetch->rec - - -Arc<RelDescriptor> +assemble->result + + - + -shd - - -shadow PG -pg_class / pg_attribute / -pg_type / pg_index / -pg_last_wal_replay_lsn() - - +capture + +CatalogCapture +boundary hold requires +returned LSN == next_lsn +diff historical predecessor +derive SchemaEvent + + -libpq->shd - - - -SELECT pg_class / pg_attribute / -pg_type / pg_index + -pg_last_wal_replay_lsn() -port=55434, unix sock - - - -prev - -prev_known -HashMap<Oid, Arc<RelDescriptor>> -last-seen shape per oid +result->capture + + +boundary fetch + + + +direct + +direct consumers +boot seed / opt-in dispatch / +backup TOAST lookup + + + +result->direct + + +non-boundary fetch - + + +worker + +walshadow worker +SCAN under SnapshotAny +own-xid visibility +replay LSN before + after + + -rec->prev - - -upsert +bridge->worker + + + +worker protocol - - -evtx - -schema_event_tx -mpsc::unbounded -(single subscriber) + + +worker->catalogs + + + +direct catalog rows - - -rec->evtx - - -Added | Changed{diff} - - - -sweep->gencnt - - -bump on DROP - - - -sweep->libpq - - -SELECT oid = ANY($1) -FROM pg_class - - - -sweep->prev - - -remove missing - - - -sweep->evtx - - -Dropped { oid, qname } + + +log + + + +DescriptorLog +append durable batch +own descriptor history + + + +capture->log + + +batch - -xbuf - -XactBuffer -stamps SchemaEvent on xid; -drain-time interleave with tuples - - - -evtx->xbuf - - -stamp on xid -(lsn-ordered drain) - - -ddlapp - -DdlApplicator -ch_ddl.rs (own CH TCP) -drains at commit boundary - - - -evtx->ddlapp - - -drain at commit +xbuf + +XactBuffer +stamp SchemaEvent on xid +commit-order drain + + + +capture->xbuf + + +Added | Changed | Dropped legend - - - -node fill — role - - - -ShadowCatalog internal (cache, gates, epochs) - - - -decoder / XactBuffer (consumer) - - - -CatalogTracker (filter, producer of bumps) - - - -DdlApplicator (CH DDL, own TCP) - - - -shadow Postgres - - -edge style - -━━ - -libpq query / SchemaEvent flow (solid = forward path) - -┄┄ - -invalidation / cache feedback (dashed) - -━━ - -CH Native DDL (off hot path, separate TCP) - - -SchemaEvent variants - -Added - -first sight of oid → CREATE TABLE (post-DROP re-create, seed_from_source) - -Changed - -refetched shape ≠ prev_known: ADD/DROP/RENAME COLUMN, type widen (rejected for now) - -Dropped - -sweep at armed commit → configured dest action; pg_toast → durable deferred mirror retire - - -generation invariant - -one DDL → one bump → every cache entry stale on next access. Coarse-fire over-invalidates (hint-bit / autovac noise count) but never under-invalidates — schema correctness is the floor, refetch cost the negotiable. - - + + + +node fill, role + + + +ShadowCatalog + bridge client + + + +shadow PG worker + catalogs + + + +capture and descriptor consumers + + + +durable descriptor log + + +edge color + +━━ + +descriptor query + assembly path + +┄┄ + +replay-pin invariant + +┈┈ + +descriptor durability + schema-event handoff + + +source policy + +committed + +worker only; ReplayMismatch fails read + +overlay + +worker only; ReplayMismatch fails read + + +ownership + +ShadowCatalog has no cache, invalidation state, or event channel. DescriptorLog owns history; CatalogCapture derives events. + + diff --git a/docker/Dockerfile b/docker/Dockerfile index 4ebabe4..d667b43 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,11 +1,11 @@ # walshadow demo image. Multi-stage on alpine/musl: # 1. rust:1-alpine -> static-ish walshadow-stream binary. alpine 3.23 # ships gcc 15, so clickhouse-c-rs builds under -std=c23. -# 2. postgres:N-alpine -> walshadow PG extension, compiled against the +# 2. postgres:N-alpine -> walshadow PG module, compiled against the # exact runtime PG (same image) so PG_MODULE_MAGIC matches. The stream # binary itself links no PG, so it builds separately. -# 3. postgres:N-alpine runtime carries both, so daemon-owned shadow PG can -# `CREATE EXTENSION walshadow` against its own $libdir. +# 3. postgres:N-alpine runtime carries both, so daemon-owned shadow PG +# resolves `shared_preload_libraries = 'walshadow'` from its own $libdir. ARG PG_MAJOR=18 @@ -51,10 +51,6 @@ RUN apk add --no-cache bash ca-certificates COPY --from=rust-builder /usr/local/bin/walshadow-stream /usr/local/bin/walshadow-stream COPY --from=ext-builder /src/pgext/walshadow.so \ /usr/local/lib/postgresql/walshadow.so -COPY --from=ext-builder /src/pgext/walshadow.control \ - /usr/local/share/postgresql/extension/walshadow.control -COPY --from=ext-builder /src/pgext/walshadow--0.1.sql \ - /usr/local/share/postgresql/extension/walshadow--0.1.sql COPY docker/entrypoint.sh /usr/local/bin/walshadow-entrypoint RUN chmod +x /usr/local/bin/walshadow-entrypoint \ diff --git a/pgext/Makefile b/pgext/Makefile index 5b02579..72ea4ad 100644 --- a/pgext/Makefile +++ b/pgext/Makefile @@ -1,4 +1,9 @@ -# walshadow — PG extension. PGXS-driven build. +# walshadow — PG loadable module. PGXS-driven build. +# +# Not an extension: no control file, no SQL script. The only consumer is a +# shadow standby whose catalog is a read-only physical copy of source's, so +# `CREATE EXTENSION` can never run there. `shared_preload_libraries` is the +# sole entry point, and decode coverage lives in the daemon's tests/bridge.rs. # # Build (against the pg_config on PATH): # make @@ -11,16 +16,12 @@ # make PG_CONFIG=/usr/lib/postgresql/17/bin/pg_config MODULE_big = walshadow -OBJS = walshadow.o - -EXTENSION = walshadow -DATA = walshadow--0.1.sql - -# `make installcheck` runs pg_regress against the *running* server pointed -# at by pg_config. pg_regress creates `contrib_regression`, CREATEs the -# extension, runs sql/.sql, and diffs against expected/.out. -REGRESS = walshadow +OBJS = decode.o overlay.o worker.o PG_CONFIG ?= pg_config PGXS := $(shell $(PG_CONFIG) --pgxs) include $(PGXS) + +# PGXS tracks no header deps, and a stale object against a bumped +# WS_PROTO_VERSION links a worker the daemon then refuses +$(OBJS): walshadow.h diff --git a/pgext/walshadow.c b/pgext/decode.c similarity index 66% rename from pgext/walshadow.c rename to pgext/decode.c index 065bb04..3834c7e 100644 --- a/pgext/walshadow.c +++ b/pgext/decode.c @@ -1,24 +1,14 @@ /* - * walshadow — shadow-PG extension used by walshadow's - * differential on-disk decode bridge. + * decode.c — on-disk Datum -> typoutput text. * - * Exposes one SQL function: - * - * walshadow_decode_disk(typoid oid, raw bytea) RETURNS text - * - * Reconstructs a Datum from on-disk bytes (varlena types get a fresh - * 4-byte header wrapped around the bytea body; fixed-width types are - * copied verbatim into a Datum slot), then runs the type's typoutput - * function. Caller — walshadow's decoder running outside shadow — - * hands over bytes it pulled from source's WAL stream, gets back the - * same text PG would render via `relation::text` from inside. - * - * Optional dependency: walshadow falls back to writing raw on-disk - * bytes into CH when this extension isn't loaded on shadow PG. + * Reconstructs a Datum from on-disk bytes (varlena types reuse the caller's + * 4-byte-header bytea directly; fixed-width types are copied into a Datum + * slot), then runs the type's typoutput function. Caller — walshadow's + * decoder running outside shadow — hands over bytes it pulled from source's + * WAL stream, gets back the same text PG would render from inside. */ - #include "postgres.h" -#include "fmgr.h" + #include "access/htup_details.h" #include "catalog/pg_type.h" #include "utils/builtins.h" @@ -26,17 +16,11 @@ #include "utils/syscache.h" #include "varatt.h" -PG_MODULE_MAGIC; +#include "walshadow.h" -PG_FUNCTION_INFO_V1(walshadow_decode_disk); - -Datum -walshadow_decode_disk(PG_FUNCTION_ARGS) +char * +ws_decode_datum_text(Oid typoid, bytea *raw) { - Oid typoid = PG_GETARG_OID(0); - /* Force 4-byte header expansion so the bytea is directly reusable - * as a same-shape varlena Datum for any target varlena type. */ - bytea *raw = PG_GETARG_BYTEA_P(1); const char *raw_data = VARDATA(raw); Size raw_len = VARSIZE(raw) - VARHDRSZ; @@ -45,10 +29,8 @@ walshadow_decode_disk(PG_FUNCTION_ARGS) int16 typlen; bool typbyval; Oid typoutput; - bool typoutput_isnull = false; Datum value; char *out; - text *result; type_tuple = SearchSysCache1(TYPEOID, ObjectIdGetDatum(typoid)); if (!HeapTupleIsValid(type_tuple)) @@ -72,6 +54,7 @@ walshadow_decode_disk(PG_FUNCTION_ARGS) { /* cstring: NUL-terminated; the on-disk body is already C-string. */ char *s = (char *) palloc(raw_len + 1); + memcpy(s, raw_data, raw_len); s[raw_len] = '\0'; value = PointerGetDatum(s); @@ -81,11 +64,15 @@ walshadow_decode_disk(PG_FUNCTION_ARGS) /* fixed pass-by-value: pack low bytes into a Datum */ Datum d = 0; Size n = (Size) typlen < sizeof(Datum) ? (Size) typlen : sizeof(Datum); + if (raw_len < n) + { + ReleaseSysCache(type_tuple); ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg("raw bytes %zu shorter than typlen %d for oid %u", raw_len, typlen, typoid))); + } memcpy(&d, raw_data, n); value = d; } @@ -93,16 +80,23 @@ walshadow_decode_disk(PG_FUNCTION_ARGS) { /* fixed pass-by-reference: heap-allocate typlen bytes */ char *p; + if (typlen <= 0) + { + ReleaseSysCache(type_tuple); ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg("non-positive typlen %d for oid %u", typlen, typoid))); + } if (raw_len < (Size) typlen) + { + ReleaseSysCache(type_tuple); ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg("raw bytes %zu shorter than typlen %d for oid %u", raw_len, typlen, typoid))); + } p = (char *) palloc(typlen); memcpy(p, raw_data, typlen); value = PointerGetDatum(p); @@ -117,10 +111,8 @@ walshadow_decode_disk(PG_FUNCTION_ARGS) } out = OidOutputFunctionCall(typoutput, value); - (void) typoutput_isnull; ReleaseSysCache(type_tuple); - result = cstring_to_text(out); - PG_RETURN_TEXT_P(result); + return out; } diff --git a/pgext/expected/walshadow.out b/pgext/expected/walshadow.out deleted file mode 100644 index cfd2da2..0000000 --- a/pgext/expected/walshadow.out +++ /dev/null @@ -1,163 +0,0 @@ --- pg_regress suite for walshadow. --- --- Exercises every branch in walshadow_decode_disk: varlena, fixed --- pass-by-value (1/2/4/8 byte), fixed pass-by-reference, cstring, plus --- STRICT NULL handling and the two ereport paths. Fixed-width tests --- assume host-endian == little-endian, matching the on-disk Datum --- layout the function reconstructs. -CREATE EXTENSION walshadow; --- ---------- varlena ----------------------------------------------------- --- text: bytea body == UTF-8 of the string. -SELECT walshadow_decode_disk('text'::regtype, 'hello'::text::bytea) = 'hello' AS text_ascii; - text_ascii ------------- - t -(1 row) - -SELECT walshadow_decode_disk('text'::regtype, 'héllo wörld'::text::bytea) = 'héllo wörld' AS text_utf8; - text_utf8 ------------ - t -(1 row) - -SELECT walshadow_decode_disk('text'::regtype, ''::text::bytea) = '' AS text_empty; - text_empty ------------- - t -(1 row) - --- varchar shares text's body shape. -SELECT walshadow_decode_disk('varchar'::regtype, 'abc'::varchar::text::bytea) AS varchar_abc; - varchar_abc -------------- - abc -(1 row) - --- bytea: identity (typoutput renders \x hex). -SELECT walshadow_decode_disk('bytea'::regtype, '\xdeadbeef'::bytea) AS bytea_hex; - bytea_hex ------------- - \xdeadbeef -(1 row) - --- json: free-form text body. -SELECT walshadow_decode_disk('json'::regtype, '{"k":1}'::text::bytea) AS json_value; - json_value ------------- - {"k":1} -(1 row) - --- Large varlena exercises the palloc + memcpy path past short-header --- territory; function always writes a 4-byte header regardless. -SELECT walshadow_decode_disk('text'::regtype, repeat('a', 1024)::text::bytea) = repeat('a', 1024) - AS text_1k; - text_1k ---------- - t -(1 row) - --- ---------- fixed pass-by-value ---------------------------------------- --- int2: 2 bytes little-endian, value 42. -SELECT walshadow_decode_disk('int2'::regtype, '\x2a00'::bytea) AS int2_42; - int2_42 ---------- - 42 -(1 row) - --- int4: 4 bytes little-endian, value 42. -SELECT walshadow_decode_disk('int4'::regtype, '\x2a000000'::bytea) AS int4_42; - int4_42 ---------- - 42 -(1 row) - --- int4: negative value -1 → 0xffffffff. -SELECT walshadow_decode_disk('int4'::regtype, '\xffffffff'::bytea) AS int4_neg1; - int4_neg1 ------------ - -1 -(1 row) - --- int8: 8 bytes little-endian, value 1234567890 = 0x499602d2. -SELECT walshadow_decode_disk('int8'::regtype, '\xd202964900000000'::bytea) AS int8_val; - int8_val ------------- - 1234567890 -(1 row) - --- bool: 1 byte. -SELECT walshadow_decode_disk('bool'::regtype, '\x01'::bytea) AS bool_t; - bool_t --------- - t -(1 row) - -SELECT walshadow_decode_disk('bool'::regtype, '\x00'::bytea) AS bool_f; - bool_f --------- - f -(1 row) - --- oid: 4 bytes little-endian, value 1234 = 0x4d2. -SELECT walshadow_decode_disk('oid'::regtype, '\xd2040000'::bytea) AS oid_1234; - oid_1234 ----------- - 1234 -(1 row) - --- float4: 4 bytes IEEE-754, value 1.0 = 0x3f800000. -SELECT walshadow_decode_disk('float4'::regtype, '\x0000803f'::bytea) AS float4_one; - float4_one ------------- - 1 -(1 row) - --- float8: 8 bytes IEEE-754, value 1.0 = 0x3ff0000000000000. -SELECT walshadow_decode_disk('float8'::regtype, '\x000000000000f03f'::bytea) AS float8_one; - float8_one ------------- - 1 -(1 row) - --- Extra raw bytes past typlen are ignored (memcpy honours typlen). -SELECT walshadow_decode_disk('int4'::regtype, '\x2a000000ffffffff'::bytea) AS int4_42_trailing; - int4_42_trailing ------------------- - 42 -(1 row) - --- ---------- fixed pass-by-reference ------------------------------------ --- uuid: 16 bytes verbatim. -SELECT walshadow_decode_disk('uuid'::regtype, - '\x00112233445566778899aabbccddeeff'::bytea) AS uuid_val; - uuid_val --------------------------------------- - 00112233-4455-6677-8899-aabbccddeeff -(1 row) - --- ---------- STRICT NULL handling --------------------------------------- --- Function is STRICT — either NULL arg short-circuits to NULL without --- entering the C body. -SELECT walshadow_decode_disk(NULL::oid, '\x00'::bytea) IS NULL AS null_oid; - null_oid ----------- - t -(1 row) - -SELECT walshadow_decode_disk('int4'::regtype, NULL::bytea) IS NULL AS null_raw; - null_raw ----------- - t -(1 row) - --- ---------- error paths ------------------------------------------------ --- Unknown type oid (chosen well above any real catalog entry). -SELECT walshadow_decode_disk(2147483647::oid, '\x00'::bytea); -ERROR: unknown type oid 2147483647 --- Raw shorter than typlen for a fixed pass-by-value type. -SELECT walshadow_decode_disk('int4'::regtype, ''::bytea); -ERROR: raw bytes 0 shorter than typlen 4 for oid 23 --- Raw shorter than typlen for a fixed pass-by-reference type. -SELECT walshadow_decode_disk('uuid'::regtype, '\x0011'::bytea); -ERROR: raw bytes 2 shorter than typlen 16 for oid 2950 -DROP EXTENSION walshadow; diff --git a/pgext/overlay.c b/pgext/overlay.c new file mode 100644 index 0000000..0332c4c --- /dev/null +++ b/pgext/overlay.c @@ -0,0 +1,445 @@ +/* + * overlay.c — read a replaying transaction's own uncommitted catalog rows. + * + * Shadow replays source WAL and is parked at some LSN L by the daemon + * withholding successor bytes. At L the catalog rows of an in-flight DDL + * transaction are on-page and uncommitted: no MVCC snapshot sees them, a + * SnapshotAny scan does. Replay being pinned at L is what does the temporal + * filtering, so no combocid machinery is needed: the latest uncommitted row + * version on the page is the state as of L. + * + * Never opens the target relation. The replaying transaction holds + * AccessExclusiveLock on it and standby lock replay is driven by the startup + * process, so relation_open would block against recovery. Catalogs only, and + * standby lock replay records AccessExclusiveLocks alone, so AccessShareLock + * on a catalog never conflicts with the DDL in flight. + * + * An invalid top xid asks the same scan for the committed view, which is what + * the daemon captures at a catalog commit. Same projections, same assembly on + * the other side, so committed and uncommitted descriptors cannot drift apart. + * + * Values are emitted in each type's text output form, one projection per + * catalog. Projections mirror daemon descriptor inputs and Rust ScanRow + * parsers. Bump WS_PROJECTION_VERSION on any change. + */ +#include "postgres.h" + +#include "access/genam.h" +#include "access/htup_details.h" +#include "access/stratnum.h" +#include "access/subtrans.h" +#include "access/table.h" +#include "access/transam.h" +#include "catalog/pg_attribute.h" +#include "catalog/pg_class.h" +#include "catalog/pg_index.h" +#include "catalog/pg_namespace.h" +#include "catalog/pg_type.h" +#include "libpq/pqformat.h" +#include "storage/procarray.h" +#include "utils/fmgroids.h" +#include "utils/fmgrprotos.h" +#include "utils/lsyscache.h" +#include "utils/rel.h" +#include "utils/snapmgr.h" + +#include "walshadow.h" + +/* ------------------------------------------------------------------------- + * column emitters + * ------------------------------------------------------------------------- */ + +static void +ws_put_null(StringInfo out) +{ + pq_sendint32(out, (uint32) -1); +} + +static void +ws_put_str(StringInfo out, const char *s) +{ + int len = (int) strlen(s); + + pq_sendint32(out, (uint32) len); + pq_sendbytes(out, s, len); +} + +static void +ws_put_oid(StringInfo out, Oid v) +{ + char buf[16]; + + snprintf(buf, sizeof(buf), "%u", v); + ws_put_str(out, buf); +} + +static void +ws_put_int(StringInfo out, int v) +{ + char buf[16]; + + snprintf(buf, sizeof(buf), "%d", v); + ws_put_str(out, buf); +} + +static void +ws_put_bool(StringInfo out, bool v) +{ + ws_put_str(out, v ? "t" : "f"); +} + +static void +ws_put_char(StringInfo out, char c) +{ + pq_sendint32(out, 1); + pq_sendbytes(out, &c, 1); +} + +static void +ws_put_name(StringInfo out, const NameData *n) +{ + ws_put_str(out, NameStr(*n)); +} + +/* ------------------------------------------------------------------------- + * visibility + * ------------------------------------------------------------------------- */ + +/* + * Does `xid` belong to the transaction tree rooted at `top`? + * + * An invalid `top` is the committed read: no transaction is the caller's, so + * every in-progress writer is foreign and the predicate degenerates to what an + * MVCC snapshot would see. Nothing to misattribute, so no mismatch to count. + * + * Standby pg_subtrans is only as complete as the XLOG_XACT_ASSIGNMENT records + * that reached it (emitted only past 64 cached subxids), so ordinary savepoint + * DDL leaves a subxact with no recorded parent, indistinguishable from a + * foreign top-level writer. A recorded chain rooting at another top is proof + * of foreign; no recorded parent is proof of nothing. + * + * Rel-scoped callers pass oids the replaying transaction holds + * AccessExclusiveLock on, which already establishes no other writer can be + * here; they trust that over missing parentage and only count the mismatch. + * Whole-catalog callers have no lock argument, and guessing either way + * returns rows that misrepresent the tree, so they fail the request. + */ +static bool +ws_xid_is_ours(TransactionId xid, TransactionId top, bool rel_scoped, + WsScanStats *stats) +{ + TransactionId resolved = xid; + + if (!TransactionIdIsValid(top)) + return false; + if (TransactionIdEquals(xid, top)) + return true; + + /* SubTransGetTopmostTransaction cannot look back past TransactionXmin */ + if (TransactionIdIsValid(TransactionXmin) && + !TransactionIdPrecedes(xid, TransactionXmin)) + resolved = SubTransGetTopmostTransaction(xid); + + if (TransactionIdEquals(resolved, top)) + return true; + + stats->subtrans_mismatch++; + if (!TransactionIdEquals(resolved, xid)) + return false; /* recorded parentage roots elsewhere */ + + if (rel_scoped) + return true; + ereport(ERROR, + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), + errmsg("walshadow overlay inconclusive: in-progress xid %u has no resolvable parent", + xid))); +} + +/* + * The transaction's own view of the row as of L: its inserts are present, its + * deletes are applied. + * + * A catalog UPDATE is delete+insert, so honouring our own xmax is what keeps + * ALTER TABLE from yielding two rows for one (attrelid, attnum). A foreign + * uncommitted delete is not ours to apply, so the row stays. + */ +static bool +ws_tuple_visible(HeapTupleHeader th, TransactionId top, bool rel_scoped, + WsScanStats *stats) +{ + TransactionId xmin; + TransactionId xmax; + + if (HeapTupleHeaderXminInvalid(th)) + return false; + + xmin = HeapTupleHeaderGetXmin(th); + if (!HeapTupleHeaderXminFrozen(th) && !HeapTupleHeaderXminCommitted(th)) + { + if (TransactionIdIsInProgress(xmin)) + { + if (!ws_xid_is_ours(xmin, top, rel_scoped, stats)) + return false; + } + else if (!TransactionIdDidCommit(xmin)) + return false; /* aborted or crashed inserter */ + } + + if ((th->t_infomask & HEAP_XMAX_INVALID) || + HEAP_XMAX_IS_LOCKED_ONLY(th->t_infomask)) + return true; + + xmax = (th->t_infomask & HEAP_XMAX_IS_MULTI) + ? HeapTupleHeaderGetUpdateXid(th) + : HeapTupleHeaderGetRawXmax(th); + if (!TransactionIdIsValid(xmax)) + return true; + + if (TransactionIdIsInProgress(xmax)) + return !ws_xid_is_ours(xmax, top, rel_scoped, stats); + + return !TransactionIdDidCommit(xmax); +} + +/* ------------------------------------------------------------------------- + * per-catalog projections + * ------------------------------------------------------------------------- */ + +static void +ws_emit_class(StringInfo out, HeapTuple tup, TupleDesc desc) +{ + Form_pg_class f = (Form_pg_class) GETSTRUCT(tup); + + ws_put_oid(out, f->oid); + ws_put_oid(out, f->relnamespace); + ws_put_name(out, &f->relname); + ws_put_char(out, f->relkind); + ws_put_char(out, f->relpersistence); + ws_put_char(out, f->relreplident); + ws_put_oid(out, f->reltoastrelid); + ws_put_oid(out, f->reltablespace); + ws_put_oid(out, f->relfilenode); +} + +static void +ws_emit_attribute(StringInfo out, HeapTuple tup, TupleDesc desc) +{ + Form_pg_attribute f = (Form_pg_attribute) GETSTRUCT(tup); + + ws_put_oid(out, f->attrelid); + ws_put_int(out, f->attnum); + ws_put_name(out, &f->attname); + ws_put_oid(out, f->atttypid); + ws_put_int(out, f->atttypmod); + ws_put_bool(out, f->attnotnull); + ws_put_bool(out, f->attisdropped); + ws_put_bool(out, f->attbyval); + ws_put_int(out, f->attlen); + ws_put_char(out, f->attalign); + ws_put_char(out, f->attstorage); + + if (!f->atthasmissing) + ws_put_null(out); + else + { + Datum d; + bool isnull; + + d = heap_getattr(tup, Anum_pg_attribute_attmissingval, desc, &isnull); + if (isnull) + ws_put_null(out); + else + { + Oid outfunc; + bool varlena; + + /* + * Stored as anyarray; anyarray_out reads the element type from + * the array header. A default whose element type was created by + * this same uncommitted transaction is invisible to that lookup + * and errors the request, degrading the capture. + */ + getTypeOutputInfo(ANYARRAYOID, &outfunc, &varlena); + ws_put_str(out, OidOutputFunctionCall(outfunc, d)); + } + } +} + +static void +ws_emit_index(StringInfo out, HeapTuple tup, TupleDesc desc) +{ + Form_pg_index f = (Form_pg_index) GETSTRUCT(tup); + + ws_put_oid(out, f->indexrelid); + ws_put_oid(out, f->indrelid); + ws_put_bool(out, f->indisprimary); + ws_put_bool(out, f->indisreplident); + /* int2vectorout form: space-separated, not the int2[] braces */ + ws_put_str(out, DatumGetCString(DirectFunctionCall1(int2vectorout, + PointerGetDatum(&f->indkey)))); +} + +static void +ws_emit_namespace(StringInfo out, HeapTuple tup, TupleDesc desc) +{ + Form_pg_namespace f = (Form_pg_namespace) GETSTRUCT(tup); + + ws_put_oid(out, f->oid); + ws_put_name(out, &f->nspname); +} + +static void +ws_emit_type(StringInfo out, HeapTuple tup, TupleDesc desc) +{ + Form_pg_type f = (Form_pg_type) GETSTRUCT(tup); + + ws_put_oid(out, f->oid); + ws_put_name(out, &f->typname); +} + +typedef void (*WsEmitRow) (StringInfo out, HeapTuple tup, TupleDesc desc); + +typedef struct WsCatalogPlan +{ + Oid relid; + Oid indexid; /* InvalidOid: no oid list is possible */ + AttrNumber keyattno; /* InvalidAttrNumber: no oid list is possible */ + /* pg_attribute only: system columns the descriptor never wants */ + int16 min_attnum; + int ncols; + WsEmitRow emit; +} WsCatalogPlan; + +static bool +ws_catalog_plan(WsCatalog cat, WsCatalogPlan *plan) +{ + switch (cat) + { + case WS_CAT_CLASS: + plan->relid = RelationRelationId; + plan->indexid = ClassOidIndexId; + plan->keyattno = Anum_pg_class_oid; + plan->min_attnum = 0; + plan->ncols = 9; + plan->emit = ws_emit_class; + return true; + case WS_CAT_ATTRIBUTE: + plan->relid = AttributeRelationId; + plan->indexid = AttributeRelidNumIndexId; + plan->keyattno = Anum_pg_attribute_attrelid; + plan->min_attnum = 1; + plan->ncols = 12; + plan->emit = ws_emit_attribute; + return true; + case WS_CAT_INDEX: + plan->relid = IndexRelationId; + plan->indexid = IndexIndrelidIndexId; + plan->keyattno = Anum_pg_index_indrelid; + plan->min_attnum = 0; + plan->ncols = 5; + plan->emit = ws_emit_index; + return true; + case WS_CAT_NAMESPACE: + plan->relid = NamespaceRelationId; + plan->indexid = InvalidOid; + plan->keyattno = InvalidAttrNumber; + plan->min_attnum = 0; + plan->ncols = 2; + plan->emit = ws_emit_namespace; + return true; + case WS_CAT_TYPE: + plan->relid = TypeRelationId; + plan->indexid = InvalidOid; + plan->keyattno = InvalidAttrNumber; + plan->min_attnum = 0; + plan->ncols = 2; + plan->emit = ws_emit_type; + return true; + } + return false; +} + +int +ws_overlay_ncols(WsCatalog cat) +{ + WsCatalogPlan plan; + + if (!ws_catalog_plan(cat, &plan)) + return -1; + return plan.ncols; +} + +/* ------------------------------------------------------------------------- + * scan + * ------------------------------------------------------------------------- */ + +static void +ws_scan_emit(Relation rel, const WsCatalogPlan *plan, Oid key, + TransactionId top, bool rel_scoped, + StringInfo out, WsScanStats *stats) +{ + SysScanDesc scan; + ScanKeyData skey[2]; + int nkeys = 0; + HeapTuple tup; + TupleDesc desc = RelationGetDescr(rel); + + if (rel_scoped) + ScanKeyInit(&skey[nkeys++], plan->keyattno, BTEqualStrategyNumber, + F_OIDEQ, ObjectIdGetDatum(key)); + if (plan->min_attnum != 0) + { + /* + * The projection is attnum >= 1 whether or not an oid list scoped it: + * pg_attribute's index is (attrelid, attnum) so a rel-scoped scan + * rides the same index, and a whole-catalog one filters on the heap. + */ + ScanKeyInit(&skey[nkeys++], Anum_pg_attribute_attnum, + BTGreaterEqualStrategyNumber, F_INT2GE, + Int16GetDatum(plan->min_attnum)); + } + + scan = systable_beginscan(rel, rel_scoped ? plan->indexid : InvalidOid, + rel_scoped, SnapshotAny, nkeys, skey); + while (HeapTupleIsValid(tup = systable_getnext(scan))) + { + stats->scanned++; + if (!ws_tuple_visible(tup->t_data, top, rel_scoped, stats)) + continue; + stats->emitted++; + plan->emit(out, tup, desc); + } + systable_endscan(scan); +} + +void +ws_overlay_scan(WsCatalog cat, TransactionId top, const Oid *oids, int noids, + StringInfo out, WsScanStats *stats) +{ + WsCatalogPlan plan; + bool rel_scoped; + Relation rel; + + if (!ws_catalog_plan(cat, &plan)) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("unknown walshadow catalog id %d", (int) cat))); + + /* An empty oid list is the whole catalog, which is the only mode + * pg_namespace and pg_type have. The lock argument comes with the list, so + * losing the list loses the argument too */ + rel_scoped = AttributeNumberIsValid(plan.keyattno) && noids > 0; + rel = table_open(plan.relid, AccessShareLock); + + if (rel_scoped) + { + int i; + + for (i = 0; i < noids; i++) + ws_scan_emit(rel, &plan, oids[i], top, true, out, stats); + } + else + ws_scan_emit(rel, &plan, InvalidOid, top, false, out, stats); + + table_close(rel, AccessShareLock); +} diff --git a/pgext/sql/walshadow.sql b/pgext/sql/walshadow.sql deleted file mode 100644 index 202bbd9..0000000 --- a/pgext/sql/walshadow.sql +++ /dev/null @@ -1,86 +0,0 @@ --- pg_regress suite for walshadow. --- --- Exercises every branch in walshadow_decode_disk: varlena, fixed --- pass-by-value (1/2/4/8 byte), fixed pass-by-reference, cstring, plus --- STRICT NULL handling and the two ereport paths. Fixed-width tests --- assume host-endian == little-endian, matching the on-disk Datum --- layout the function reconstructs. - -CREATE EXTENSION walshadow; - --- ---------- varlena ----------------------------------------------------- - --- text: bytea body == UTF-8 of the string. -SELECT walshadow_decode_disk('text'::regtype, 'hello'::text::bytea) = 'hello' AS text_ascii; -SELECT walshadow_decode_disk('text'::regtype, 'héllo wörld'::text::bytea) = 'héllo wörld' AS text_utf8; -SELECT walshadow_decode_disk('text'::regtype, ''::text::bytea) = '' AS text_empty; - --- varchar shares text's body shape. -SELECT walshadow_decode_disk('varchar'::regtype, 'abc'::varchar::text::bytea) AS varchar_abc; - --- bytea: identity (typoutput renders \x hex). -SELECT walshadow_decode_disk('bytea'::regtype, '\xdeadbeef'::bytea) AS bytea_hex; - --- json: free-form text body. -SELECT walshadow_decode_disk('json'::regtype, '{"k":1}'::text::bytea) AS json_value; - --- Large varlena exercises the palloc + memcpy path past short-header --- territory; function always writes a 4-byte header regardless. -SELECT walshadow_decode_disk('text'::regtype, repeat('a', 1024)::text::bytea) = repeat('a', 1024) - AS text_1k; - --- ---------- fixed pass-by-value ---------------------------------------- - --- int2: 2 bytes little-endian, value 42. -SELECT walshadow_decode_disk('int2'::regtype, '\x2a00'::bytea) AS int2_42; - --- int4: 4 bytes little-endian, value 42. -SELECT walshadow_decode_disk('int4'::regtype, '\x2a000000'::bytea) AS int4_42; - --- int4: negative value -1 → 0xffffffff. -SELECT walshadow_decode_disk('int4'::regtype, '\xffffffff'::bytea) AS int4_neg1; - --- int8: 8 bytes little-endian, value 1234567890 = 0x499602d2. -SELECT walshadow_decode_disk('int8'::regtype, '\xd202964900000000'::bytea) AS int8_val; - --- bool: 1 byte. -SELECT walshadow_decode_disk('bool'::regtype, '\x01'::bytea) AS bool_t; -SELECT walshadow_decode_disk('bool'::regtype, '\x00'::bytea) AS bool_f; - --- oid: 4 bytes little-endian, value 1234 = 0x4d2. -SELECT walshadow_decode_disk('oid'::regtype, '\xd2040000'::bytea) AS oid_1234; - --- float4: 4 bytes IEEE-754, value 1.0 = 0x3f800000. -SELECT walshadow_decode_disk('float4'::regtype, '\x0000803f'::bytea) AS float4_one; - --- float8: 8 bytes IEEE-754, value 1.0 = 0x3ff0000000000000. -SELECT walshadow_decode_disk('float8'::regtype, '\x000000000000f03f'::bytea) AS float8_one; - --- Extra raw bytes past typlen are ignored (memcpy honours typlen). -SELECT walshadow_decode_disk('int4'::regtype, '\x2a000000ffffffff'::bytea) AS int4_42_trailing; - --- ---------- fixed pass-by-reference ------------------------------------ - --- uuid: 16 bytes verbatim. -SELECT walshadow_decode_disk('uuid'::regtype, - '\x00112233445566778899aabbccddeeff'::bytea) AS uuid_val; - --- ---------- STRICT NULL handling --------------------------------------- - --- Function is STRICT — either NULL arg short-circuits to NULL without --- entering the C body. -SELECT walshadow_decode_disk(NULL::oid, '\x00'::bytea) IS NULL AS null_oid; -SELECT walshadow_decode_disk('int4'::regtype, NULL::bytea) IS NULL AS null_raw; - --- ---------- error paths ------------------------------------------------ - --- Unknown type oid (chosen well above any real catalog entry). -SELECT walshadow_decode_disk(2147483647::oid, '\x00'::bytea); - --- Raw shorter than typlen for a fixed pass-by-value type. -SELECT walshadow_decode_disk('int4'::regtype, ''::bytea); - --- Raw shorter than typlen for a fixed pass-by-reference type. -SELECT walshadow_decode_disk('uuid'::regtype, '\x0011'::bytea); - -DROP EXTENSION walshadow; diff --git a/pgext/walshadow--0.1.sql b/pgext/walshadow--0.1.sql deleted file mode 100644 index 23b4ae6..0000000 --- a/pgext/walshadow--0.1.sql +++ /dev/null @@ -1,10 +0,0 @@ --- walshadow 0.1. -\echo Use "CREATE EXTENSION walshadow" to load this file. \quit - -CREATE FUNCTION walshadow_decode_disk(typoid oid, raw bytea) -RETURNS text -AS 'MODULE_PATHNAME', 'walshadow_decode_disk' -LANGUAGE C STRICT IMMUTABLE; - -COMMENT ON FUNCTION walshadow_decode_disk(oid, bytea) IS - 'Decode an on-disk Datum body via typoutput; used by walshadow''s decode bridge.'; diff --git a/pgext/walshadow.control b/pgext/walshadow.control deleted file mode 100644 index 9215cad..0000000 --- a/pgext/walshadow.control +++ /dev/null @@ -1,4 +0,0 @@ -comment = 'walshadow Phase 9 decode bridge: on-disk Datum → typoutput text' -default_version = '0.1' -module_pathname = '$libdir/walshadow' -relocatable = true diff --git a/pgext/walshadow.h b/pgext/walshadow.h new file mode 100644 index 0000000..5795da2 --- /dev/null +++ b/pgext/walshadow.h @@ -0,0 +1,76 @@ +/* + * walshadow — shared declarations for the shadow-side module. + * + * One entry point: a background worker reached via + * shared_preload_libraries. Needs no catalog row, which is the whole point — + * a shadow standby's catalog is a read-only physical copy of source's, so + * anything requiring a pg_proc row is unreachable there. + * + * Wire encoding is network byte order throughout (pqformat's pq_send*), + * matching PG's own convention rather than the daemon's native LE. + */ +#ifndef WALSHADOW_H +#define WALSHADOW_H + +#include "postgres.h" + +#include "lib/stringinfo.h" + +/* Bumped when a request or response layout changes, or when an op's reading + * of an unchanged layout changes */ +#define WS_PROTO_VERSION 1 +/* Bumped when any catalog projection changes shape */ +#define WS_PROJECTION_VERSION 1 + +/* request opcodes */ +#define WS_OP_HELLO 0x01 +#define WS_OP_DECODE 0x02 +#define WS_OP_SCAN 0x03 +#define WS_OP_REPLAY_LSN 0x04 + +/* response status byte */ +#define WS_STATUS_OK 0x00 +#define WS_STATUS_ERROR 0x01 + +/* per-item kind in a DECODE response */ +#define WS_ITEM_TEXT 0x00 +#define WS_ITEM_ERROR 0x01 + +/* + * Catalogs the overlay scan covers. Ids are wire values; never renumber. + */ +typedef enum WsCatalog +{ + WS_CAT_CLASS = 1, + WS_CAT_ATTRIBUTE = 2, + WS_CAT_INDEX = 3, + WS_CAT_NAMESPACE = 4, + WS_CAT_TYPE = 5, +} WsCatalog; + +/* decode.c */ +extern char *ws_decode_datum_text(Oid typoid, bytea *raw); + +/* overlay.c */ +typedef struct WsScanStats +{ + uint32 scanned; + uint32 emitted; + /* writers that did not resolve to the requested top xid: provably foreign, + * or (rel-scoped only) unresolvable and trusted ours by the lock argument. + * Whole-catalog scans error on an unresolvable writer instead. A committed + * read owns no transaction, so nothing lands here */ + uint32 subtrans_mismatch; +} WsScanStats; + +extern int ws_overlay_ncols(WsCatalog cat); + +/* + * `top` invalid reads the committed view; an empty `oids` reads the whole + * catalog, which is the only mode pg_namespace and pg_type have. + */ +extern void ws_overlay_scan(WsCatalog cat, TransactionId top, + const Oid *oids, int noids, + StringInfo out, WsScanStats *stats); + +#endif /* WALSHADOW_H */ diff --git a/pgext/worker.c b/pgext/worker.c new file mode 100644 index 0000000..2832178 --- /dev/null +++ b/pgext/worker.c @@ -0,0 +1,798 @@ +/* + * worker.c — walshadow bridge background worker. + * + * Serves catalog reads and on-disk decode to the walshadow daemon over a unix + * socket. Loaded through shared_preload_libraries, so it needs no pg_proc row + * and no CREATE EXTENSION: on a shadow standby the catalog is a physical copy + * of source's and cannot be written. walshadow writes shadow's + * postgresql.conf, so the worker is something it can guarantee. + * + * One request at a time, whichever connection is ready first. Every request + * runs in its own transaction; errors are caught, reported on the wire, and + * the loop continues. walshadow.h defines protocol constants and layouts. + */ +#include "postgres.h" + +#include +#include +#include +#include +#include +#include + +#include "access/xact.h" +#include "access/xlog.h" +#include "access/xlogrecovery.h" +#include "fmgr.h" +#include "libpq/pqformat.h" +#include "miscadmin.h" +#include "pgstat.h" +#include "port/pg_bswap.h" +#include "postmaster/bgworker.h" +#include "postmaster/interrupt.h" +#include "storage/ipc.h" +#include "storage/latch.h" +#include "utils/guc.h" +#include "utils/memutils.h" +#include "utils/snapmgr.h" +#include "utils/timestamp.h" +#include "utils/wait_event.h" + +#include "walshadow.h" + +PG_MODULE_MAGIC; + +#define WS_MAX_CONNS 8 +#define WS_LISTEN_BACKLOG 16 +#define WS_IDLE_POLL_MS 1000 +#define WS_MAX_SCAN_OIDS 65536 + +/* wait-set positions, in the order ws_build_wait_set adds them */ +#define WS_POS_LATCH 0 +#define WS_POS_PM_DEATH 1 +#define WS_POS_LISTEN 2 +#define WS_POS_CONN0 3 + +PGDLLEXPORT void ws_worker_main(Datum main_arg); + +static char *ws_socket_path = NULL; +static char *ws_database = NULL; +static int ws_io_timeout_ms = 30000; +static int ws_lock_timeout_ms = 1000; +static int ws_max_request_mb = 64; + +static MemoryContext ws_request_ctx = NULL; +static char ws_bound_path[MAXPGPATH]; + +/* ------------------------------------------------------------------------- + * socket plumbing + * ------------------------------------------------------------------------- */ + +static void +ws_unlink_socket(int code, Datum arg) +{ + if (ws_bound_path[0] != '\0') + (void) unlink(ws_bound_path); +} + +/* + * A leftover socket file from a crash is walshadow's to remove, but anything + * else on the path is a misconfiguration and unlinking it would destroy data + * that is not ours. + */ +static void +ws_reject_non_socket(const char *path) +{ + struct stat st; + + if (lstat(path, &st) < 0) + return; /* absent (or unreadable, and bind will say) */ + if (!S_ISSOCK(st.st_mode)) + ereport(ERROR, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("walshadow.socket_path \"%s\" exists and is not a socket", + path))); +} + +/* + * A live listener on the same path is another cluster's worker and taking it + * over would silently answer that daemon's requests from the wrong catalog. + */ +static bool +ws_path_has_listener(const char *path) +{ + struct sockaddr_un addr; + pgsocket fd; + bool alive; + + fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd == PGINVALID_SOCKET) + return true; /* cannot prove it is dead */ + + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strlcpy(addr.sun_path, path, sizeof(addr.sun_path)); + alive = connect(fd, (struct sockaddr *) &addr, sizeof(addr)) == 0; + closesocket(fd); + return alive; +} + +static pgsocket +ws_listen(const char *path) +{ + struct sockaddr_un addr; + pgsocket fd; + + if (strlen(path) >= sizeof(addr.sun_path)) + ereport(ERROR, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("walshadow.socket_path is longer than %zu bytes", + sizeof(addr.sun_path) - 1))); + + ws_reject_non_socket(path); + if (ws_path_has_listener(path)) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_IN_USE), + errmsg("walshadow.socket_path \"%s\" already has a listener", + path))); + (void) unlink(path); + + fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd == PGINVALID_SOCKET) + ereport(ERROR, + (errcode_for_socket_access(), + errmsg("walshadow: could not create socket: %m"))); + + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strlcpy(addr.sun_path, path, sizeof(addr.sun_path)); + if (bind(fd, (struct sockaddr *) &addr, sizeof(addr)) < 0) + { + closesocket(fd); + ereport(ERROR, + (errcode_for_socket_access(), + errmsg("walshadow: could not bind \"%s\": %m", path))); + } + + strlcpy(ws_bound_path, path, sizeof(ws_bound_path)); + on_proc_exit(ws_unlink_socket, 0); + + /* Decode runs arbitrary typoutput over caller-supplied bytes and the scan + * reads any catalog row, so the socket is owner-only. */ + if (chmod(path, S_IRUSR | S_IWUSR) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("walshadow: could not chmod \"%s\": %m", path))); + + if (listen(fd, WS_LISTEN_BACKLOG) < 0) + ereport(ERROR, + (errcode_for_socket_access(), + errmsg("walshadow: could not listen on \"%s\": %m", path))); + + if (!pg_set_noblock(fd)) + ereport(ERROR, + (errcode_for_socket_access(), + errmsg("walshadow: could not set socket non-blocking: %m"))); + + return fd; +} + +/* + * Wait for `event` on one socket. `false` means the caller should abandon the + * connection: shutdown was requested. + */ +static bool +ws_wait_socket(pgsocket fd, int event, long timeout_ms) +{ + int rc; + + rc = WaitLatchOrSocket(MyLatch, + WL_LATCH_SET | WL_EXIT_ON_PM_DEATH | WL_TIMEOUT | event, + fd, timeout_ms, PG_WAIT_EXTENSION); + if (rc & WL_LATCH_SET) + { + ResetLatch(MyLatch); + CHECK_FOR_INTERRUPTS(); + if (ConfigReloadPending) + { + ConfigReloadPending = false; + ProcessConfigFile(PGC_SIGHUP); + } + if (ShutdownRequestPending) + return false; + } + return true; +} + +static bool +ws_read_exact(pgsocket fd, char *buf, size_t len) +{ + size_t got = 0; + TimestampTz deadline; + + deadline = TimestampTzPlusMilliseconds(GetCurrentTimestamp(), + ws_io_timeout_ms); + while (got < len) + { + ssize_t r; + long wait_ms; + + r = recv(fd, buf + got, len - got, 0); + if (r > 0) + { + got += (size_t) r; + continue; + } + if (r == 0) + return false; /* peer closed */ + if (errno == EINTR) + continue; + if (errno != EAGAIN && errno != EWOULDBLOCK) + { + ereport(LOG, + (errcode_for_socket_access(), + errmsg("walshadow: recv failed: %m"))); + return false; + } + + wait_ms = TimestampDifferenceMilliseconds(GetCurrentTimestamp(), + deadline); + if (wait_ms <= 0) + { + ereport(LOG, + (errmsg("walshadow: read timed out after %d ms", + ws_io_timeout_ms))); + return false; + } + if (!ws_wait_socket(fd, WL_SOCKET_READABLE, wait_ms)) + return false; + } + return true; +} + +static bool +ws_write_all(pgsocket fd, const char *buf, size_t len) +{ + size_t sent = 0; + TimestampTz deadline; + + deadline = TimestampTzPlusMilliseconds(GetCurrentTimestamp(), + ws_io_timeout_ms); + while (sent < len) + { + ssize_t w; + long wait_ms; + + w = send(fd, buf + sent, len - sent, 0); + if (w > 0) + { + sent += (size_t) w; + continue; + } + if (w == 0) + return false; /* len > 0, so no progress and errno is stale */ + if (errno == EINTR) + continue; + if (errno != EAGAIN && errno != EWOULDBLOCK) + { + ereport(LOG, + (errcode_for_socket_access(), + errmsg("walshadow: send failed: %m"))); + return false; + } + + wait_ms = TimestampDifferenceMilliseconds(GetCurrentTimestamp(), + deadline); + if (wait_ms <= 0) + { + ereport(LOG, + (errmsg("walshadow: write timed out after %d ms", + ws_io_timeout_ms))); + return false; + } + if (!ws_wait_socket(fd, WL_SOCKET_WRITEABLE, wait_ms)) + return false; + } + return true; +} + +/* ------------------------------------------------------------------------- + * request handlers + * ------------------------------------------------------------------------- */ + +static void +ws_put_lenstr(StringInfo out, const char *s) +{ + int len = (int) strlen(s); + + pq_sendint32(out, (uint32) len); + pq_sendbytes(out, s, len); +} + +static void +ws_handle_decode(StringInfo req, StringInfo resp) +{ + uint32 nitems = pq_getmsgint(req, 4); + uint32 i; + + pq_sendbyte(resp, WS_STATUS_OK); + pq_sendint32(resp, nitems); + + for (i = 0; i < nitems; i++) + { + Oid typoid = (Oid) pq_getmsgint(req, 4); + uint32 len = pq_getmsgint(req, 4); + const char *bytes = pq_getmsgbytes(req, (int) len); + MemoryContext oldctx = CurrentMemoryContext; + ResourceOwner oldowner = CurrentResourceOwner; + int mark = resp->len; + + /* + * typoutput on bytes walshadow reconstructed from WAL can raise on + * anything from a short body to a type whose input assumptions the + * decoder got wrong. One bad value must not cost the batch. + */ + BeginInternalSubTransaction(NULL); + MemoryContextSwitchTo(oldctx); + PG_TRY(); + { + bytea *raw = (bytea *) palloc(VARHDRSZ + len); + char *txt; + + SET_VARSIZE(raw, VARHDRSZ + len); + memcpy(VARDATA(raw), bytes, len); + + /* Decode before writing the marker: a throw mid-item must not + * leave a half-written item behind. */ + txt = ws_decode_datum_text(typoid, raw); + pq_sendbyte(resp, WS_ITEM_TEXT); + ws_put_lenstr(resp, txt); + + ReleaseCurrentSubTransaction(); + MemoryContextSwitchTo(oldctx); + CurrentResourceOwner = oldowner; + } + PG_CATCH(); + { + ErrorData *edata; + + MemoryContextSwitchTo(oldctx); + edata = CopyErrorData(); + FlushErrorState(); + RollbackAndReleaseCurrentSubTransaction(); + MemoryContextSwitchTo(oldctx); + CurrentResourceOwner = oldowner; + + resp->len = mark; + resp->data[mark] = '\0'; + pq_sendbyte(resp, WS_ITEM_ERROR); + ws_put_lenstr(resp, edata->message); + FreeErrorData(edata); + } + PG_END_TRY(); + } +} + +static void +ws_handle_scan(StringInfo req, StringInfo resp) +{ + WsCatalog cat = (WsCatalog) pq_getmsgbyte(req); + TransactionId top = (TransactionId) pq_getmsgint(req, 4); + uint32 noids = pq_getmsgint(req, 4); + int ncols = ws_overlay_ncols(cat); + Oid *oids = NULL; + StringInfoData rows; + WsScanStats stats = {0, 0, 0}; + uint64 lsn_start; + uint64 lsn_end; + uint32 i; + + if (ncols < 0) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("unknown walshadow catalog id %d", (int) cat))); + if (noids > WS_MAX_SCAN_OIDS) + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("walshadow scan oid list of %u exceeds %d", + noids, WS_MAX_SCAN_OIDS))); + + if (noids > 0) + { + oids = palloc_array(Oid, noids); + for (i = 0; i < noids; i++) + oids[i] = (Oid) pq_getmsgint(req, 4); + } + + initStringInfo(&rows); + /* + * Caller asserts both LSNs equal the boundary it parked replay at. Equal + * but wrong is impossible: replay cannot rewind, and the daemon holds the + * successor bytes. + */ + lsn_start = (uint64) GetXLogReplayRecPtr(NULL); + ws_overlay_scan(cat, top, oids, (int) noids, &rows, &stats); + lsn_end = (uint64) GetXLogReplayRecPtr(NULL); + + pq_sendbyte(resp, WS_STATUS_OK); + pq_sendint64(resp, lsn_start); + pq_sendint64(resp, lsn_end); + pq_sendint32(resp, stats.scanned); + pq_sendint32(resp, stats.subtrans_mismatch); + pq_sendint32(resp, stats.emitted); + pq_sendint16(resp, (uint16) ncols); + pq_sendbytes(resp, rows.data, rows.len); +} + +static void +ws_dispatch(StringInfo req, StringInfo resp) +{ + MemoryContext oldctx = CurrentMemoryContext; + /* written after PG_TRY, read after siglongjmp: must be volatile */ + volatile bool in_xact = false; + + PG_TRY(); + { + uint8 op = pq_getmsgbyte(req); + + if (op == WS_OP_DECODE || op == WS_OP_SCAN) + { + SetCurrentStatementStartTimestamp(); + StartTransactionCommand(); + PushActiveSnapshot(GetTransactionSnapshot()); + in_xact = true; + } + + switch (op) + { + case WS_OP_HELLO: + pq_sendbyte(resp, WS_STATUS_OK); + pq_sendint32(resp, WS_PROTO_VERSION); + pq_sendint32(resp, WS_PROJECTION_VERSION); + pq_sendint32(resp, PG_VERSION_NUM); + pq_sendbyte(resp, RecoveryInProgress() ? 1 : 0); + break; + case WS_OP_REPLAY_LSN: + pq_sendbyte(resp, WS_STATUS_OK); + pq_sendint64(resp, (uint64) GetXLogReplayRecPtr(NULL)); + break; + case WS_OP_DECODE: + ws_handle_decode(req, resp); + break; + case WS_OP_SCAN: + ws_handle_scan(req, resp); + break; + default: + ereport(ERROR, + (errcode(ERRCODE_PROTOCOL_VIOLATION), + errmsg("unknown walshadow opcode %u", op))); + } + + /* trailing bytes mean the peer framed a different request */ + pq_getmsgend(req); + + if (in_xact) + { + PopActiveSnapshot(); + CommitTransactionCommand(); + in_xact = false; + } + } + PG_CATCH(); + { + ErrorData *edata; + + HOLD_INTERRUPTS(); + MemoryContextSwitchTo(oldctx); + edata = CopyErrorData(); + FlushErrorState(); + if (in_xact) + AbortCurrentTransaction(); + + resetStringInfo(resp); + pq_sendbyte(resp, WS_STATUS_ERROR); + ws_put_lenstr(resp, edata->message); + FreeErrorData(edata); + RESUME_INTERRUPTS(); + } + PG_END_TRY(); + + MemoryContextSwitchTo(oldctx); +} + +/* + * Read one framed request, answer it, write one framed response. + * `false` closes the connection. + */ +static bool +ws_serve_request(pgsocket fd) +{ + uint32 hdr; + uint32 len; + /* enlargeStringInfo ERRORs at MaxAllocSize, and that would happen outside + * the request catch; reject before allocating */ + Size max_len = Min((Size) ws_max_request_mb << 20, MaxAllocSize - 1); + StringInfoData req; + StringInfoData resp; + MemoryContext oldctx; + bool ok = false; + + if (!ws_read_exact(fd, (char *) &hdr, sizeof(hdr))) + return false; + len = pg_ntoh32(hdr); + + if (len < 1 || (Size) len > max_len) + { + ereport(LOG, + (errmsg("walshadow: rejecting request frame of %u bytes", len))); + return false; + } + + oldctx = MemoryContextSwitchTo(ws_request_ctx); + + initStringInfo(&req); + enlargeStringInfo(&req, (int) len); + if (ws_read_exact(fd, req.data, len)) + { + req.len = (int) len; + req.data[len] = '\0'; + + /* resp is payload only; the dispatcher may reset it wholesale on + * error, so the frame prefix cannot live in the same buffer */ + initStringInfo(&resp); + ws_dispatch(&req, &resp); + + hdr = pg_hton32((uint32) resp.len); + ok = ws_write_all(fd, (char *) &hdr, sizeof(hdr)) && + ws_write_all(fd, resp.data, (size_t) resp.len); + } + + MemoryContextSwitchTo(oldctx); + MemoryContextReset(ws_request_ctx); + return ok; +} + +/* ------------------------------------------------------------------------- + * worker main + * ------------------------------------------------------------------------- */ + +/* + * Rebuilt per iteration: the wait-set API has no portable event removal, and + * this loop is idle-dominated. + */ +static WaitEventSet * +ws_build_wait_set(pgsocket listen_fd, const pgsocket *conns, int nconns) +{ + WaitEventSet *set; + int i; + +#if PG_VERSION_NUM >= 170000 + set = CreateWaitEventSet(NULL, nconns + WS_POS_CONN0); +#else + set = CreateWaitEventSet(CurrentMemoryContext, nconns + WS_POS_CONN0); +#endif + AddWaitEventToSet(set, WL_LATCH_SET, PGINVALID_SOCKET, MyLatch, NULL); + AddWaitEventToSet(set, WL_EXIT_ON_PM_DEATH, PGINVALID_SOCKET, NULL, NULL); + AddWaitEventToSet(set, WL_SOCKET_READABLE, listen_fd, NULL, NULL); + for (i = 0; i < nconns; i++) + AddWaitEventToSet(set, WL_SOCKET_READABLE, conns[i], NULL, NULL); + return set; +} + +static void +ws_accept(pgsocket listen_fd, pgsocket *conns, int *nconns) +{ + pgsocket fd; + + fd = accept(listen_fd, NULL, NULL); + if (fd == PGINVALID_SOCKET) + { + if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) + ereport(LOG, + (errcode_for_socket_access(), + errmsg("walshadow: accept failed: %m"))); + return; + } + if (*nconns >= WS_MAX_CONNS) + { + ereport(LOG, + (errmsg("walshadow: refusing connection, %d already open", + WS_MAX_CONNS))); + closesocket(fd); + return; + } + if (!pg_set_noblock(fd)) + { + ereport(LOG, + (errcode_for_socket_access(), + errmsg("walshadow: could not set client socket non-blocking: %m"))); + closesocket(fd); + return; + } + conns[(*nconns)++] = fd; +} + +static void +ws_drop_conn(pgsocket *conns, int *nconns, int idx) +{ + closesocket(conns[idx]); + conns[idx] = conns[--*nconns]; +} + +static void +ws_serve_loop(pgsocket listen_fd) +{ + pgsocket conns[WS_MAX_CONNS]; + int nconns = 0; + + for (;;) + { + WaitEventSet *set; + WaitEvent events[WS_MAX_CONNS + WS_POS_CONN0]; + int nready; + int i; + int serve = -1; + + CHECK_FOR_INTERRUPTS(); + if (ConfigReloadPending) + { + ConfigReloadPending = false; + ProcessConfigFile(PGC_SIGHUP); + } + if (ShutdownRequestPending) + break; + + set = ws_build_wait_set(listen_fd, conns, nconns); + nready = WaitEventSetWait(set, WS_IDLE_POLL_MS, events, + lengthof(events), PG_WAIT_EXTENSION); + FreeWaitEventSet(set); + + for (i = 0; i < nready; i++) + { + int pos = events[i].pos; + + if (pos == WS_POS_LATCH) + ResetLatch(MyLatch); + else if (pos == WS_POS_LISTEN) + ws_accept(listen_fd, conns, &nconns); + else if (serve < 0) + serve = pos - WS_POS_CONN0; + } + + /* + * One request per iteration. Dropping a connection reorders the + * array, so any other ready position from this round is stale; they + * stay readable and are picked up next time round. + */ + if (serve >= 0 && serve < nconns) + { + pgstat_report_activity(STATE_RUNNING, "walshadow request"); + if (!ws_serve_request(conns[serve])) + ws_drop_conn(conns, &nconns, serve); + pgstat_report_activity(STATE_IDLE, NULL); + } + } + + while (nconns > 0) + ws_drop_conn(conns, &nconns, 0); + closesocket(listen_fd); +} + +void +ws_worker_main(Datum main_arg) +{ + pgsocket listen_fd; + char buf[32]; + + pqsignal(SIGTERM, SignalHandlerForShutdownRequest); + pqsignal(SIGHUP, SignalHandlerForConfigReload); + BackgroundWorkerUnblockSignals(); + + BackgroundWorkerInitializeConnection(ws_database, NULL, 0); + + /* + * The replaying transaction holds AccessExclusiveLock on its own + * relations, and standby lock replay is the startup process. A catalog + * lock we cannot get is a hang against recovery, so bound it. + */ + snprintf(buf, sizeof(buf), "%d", ws_lock_timeout_ms); + SetConfigOption("lock_timeout", buf, PGC_SUSET, PGC_S_OVERRIDE); + + /* + * typoutput is not a pure function of the bytes: timestamptz follows + * TimeZone, dates DateStyle, interval IntervalStyle, bytea bytea_output, + * floats extra_float_digits. The connection inherits whatever database and + * role defaults replicated from source, so pin a canonical environment; + * PGC_S_OVERRIDE outranks any SIGHUP reload. + */ + SetConfigOption("TimeZone", "UTC", PGC_SUSET, PGC_S_OVERRIDE); + SetConfigOption("DateStyle", "ISO,MDY", PGC_SUSET, PGC_S_OVERRIDE); + SetConfigOption("IntervalStyle", "postgres", PGC_SUSET, PGC_S_OVERRIDE); + SetConfigOption("extra_float_digits", "1", PGC_SUSET, PGC_S_OVERRIDE); + SetConfigOption("bytea_output", "hex", PGC_SUSET, PGC_S_OVERRIDE); + + ws_request_ctx = AllocSetContextCreate(TopMemoryContext, + "walshadow request", + ALLOCSET_DEFAULT_SIZES); + + listen_fd = ws_listen(ws_socket_path); + ereport(LOG, + (errmsg("walshadow bridge listening on \"%s\" (proto %d)", + ws_socket_path, WS_PROTO_VERSION))); + + ws_serve_loop(listen_fd); + + /* + * Postmaster reads exit 0 as "terminate, forget this worker" and exit 1 as + * FATAL, which bgw_restart_time then covers (CleanupBackgroundWorker). + * pg_terminate_backend and a recovery conflict both arrive as SIGTERM, and + * neither should cost the bridge until the next cluster restart. Nothing + * restarts during postmaster shutdown, so this only costs a LOG line there. + */ + proc_exit(1); +} + +void +_PG_init(void) +{ + BackgroundWorker worker; + + /* + * Worker registration and its postmaster-scoped GUCs are only legal + * during preload; a bare LOAD gets nothing. + */ + if (!process_shared_preload_libraries_in_progress) + return; + + DefineCustomStringVariable("walshadow.socket_path", + "Unix socket the walshadow bridge listens on.", + "Empty disables the worker.", + &ws_socket_path, + "", + PGC_POSTMASTER, 0, + NULL, NULL, NULL); + DefineCustomStringVariable("walshadow.database", + "Database the walshadow bridge connects to.", + NULL, + &ws_database, + "postgres", + PGC_POSTMASTER, 0, + NULL, NULL, NULL); + DefineCustomIntVariable("walshadow.io_timeout_ms", + "Abandon a bridge connection stalled this long.", + NULL, + &ws_io_timeout_ms, + 30000, 100, INT_MAX, + PGC_SIGHUP, GUC_UNIT_MS, + NULL, NULL, NULL); + DefineCustomIntVariable("walshadow.lock_timeout_ms", + "lock_timeout the bridge applies to its own reads.", + NULL, + &ws_lock_timeout_ms, + 1000, 0, INT_MAX, + PGC_POSTMASTER, GUC_UNIT_MS, + NULL, NULL, NULL); + DefineCustomIntVariable("walshadow.max_request_mb", + "Largest request frame the bridge accepts.", + NULL, + &ws_max_request_mb, + 64, 1, 1024, + PGC_SIGHUP, 0, + NULL, NULL, NULL); + + MarkGUCPrefixReserved("walshadow"); + + if (ws_socket_path == NULL || ws_socket_path[0] == '\0') + return; + + memset(&worker, 0, sizeof(worker)); + worker.bgw_flags = BGWORKER_SHMEM_ACCESS | BGWORKER_BACKEND_DATABASE_CONNECTION; + /* Catalog reads need a database connection, so not before consistency */ + worker.bgw_start_time = BgWorkerStart_ConsistentState; + worker.bgw_restart_time = 5; + strlcpy(worker.bgw_library_name, "walshadow", BGW_MAXLEN); + strlcpy(worker.bgw_function_name, "ws_worker_main", BGW_MAXLEN); + strlcpy(worker.bgw_name, "walshadow bridge", BGW_MAXLEN); + strlcpy(worker.bgw_type, "walshadow bridge", BGW_MAXLEN); + RegisterBackgroundWorker(&worker); +} diff --git a/plans/GLOSSARY.md b/plans/GLOSSARY.md index 43ecb54..fdf7f24 100644 --- a/plans/GLOSSARY.md +++ b/plans/GLOSSARY.md @@ -67,6 +67,10 @@ config writes. Shared by hot path and gap replay bootstrap but never settle on shadow's data dir; keeps shadow MiB-scale by construction ([bootstrap.md](bootstrap.md)) +**catalog projection** — the column plan `SCAN` returns per catalog, and +the single input to descriptor assembly. Required worker emits identical +projection forms for committed and overlay reads ([shadow.md](shadow.md)) + **catalog replay** — shadow PG applying filtered catalog WAL, so `pg_catalog` tracks source DDL and relfilenode rewrites with zero operator coordination ([overview.md](overview.md)) @@ -333,8 +337,8 @@ whole overlay subsystem; `config_table.replicate` opts one table into replication, triggering backfill per `initial_load` ([config.md](config.md), [add_table.md](add_table.md)) -**oracle** — PgPending resolver: shadow decodes on-disk bytes -via `walshadow_decode_disk` (same `typoutput` PG would call) into text +**oracle** — PgPending resolver: shadow decodes on-disk bytes through +the bridge worker's `DECODE` op (same `typoutput` PG would call) into text post-plan; best-effort, unresolved values ship raw bytes ([oracle.md](oracle.md)) @@ -347,6 +351,13 @@ barrier segments PG, replicated through WAL, applied at each row's commit LSN ([config.md](config.md)) +**overlay scan** — unrelated to the config overlay: the bridge worker's +`SCAN` op reading one uncommitted transaction's own catalog rows off +shadow's pages under `SnapshotAny`, with replay parked at the caller's +boundary; `fetch_overlay_descriptors` assembles them into descriptors. +Top xid `0` asks the same scan for the committed view instead +([oracle.md](oracle.md), [shadow.md](shadow.md)) + **PageWalkSink** — Tap sink walking user-heap backup pages 8 KiB at a time, decoding `LP_NORMAL` slots through shared heap decoder, emitting BackfillTuples with per-rel `_lsn` overrides @@ -358,12 +369,13 @@ rows) between source and CH proving replication fidelity **PgPending** — ColumnValue fallback `{type_oid, raw}` for types without in-tree codec (jsonb, ranges, arrays, tsvector, vendor types); resolved -at emit via oracle bridge, raw bytes pass through when extension absent -([decoder.md](decoder.md), [oracle.md](oracle.md)) +at emit via required oracle bridge; per-item `typoutput` errors preserve raw +bytes ([decoder.md](decoder.md), [oracle.md](oracle.md)) -**pgext** — walshadow PG extension (PGXS, shadow-only) exposing -`walshadow_decode_disk(oid, bytea) -> text` -([oracle.md](oracle.md)) +**pgext** — walshadow PG module (PGXS, shadow-only), loaded through +`shared_preload_libraries` rather than `CREATE EXTENSION`; serves catalog +reads and on-disk decode over a unix socket +([oracle.md](oracle.md), [`pgext/walshadow.h`](../pgext/walshadow.h)) **PgXactAccum / PgXactPatch** — backup-era `pg_xact` accumulated from backup files, patched with commit/abort records harvested from gap-WAL diff --git a/plans/config.md b/plans/config.md index ff166b8..103522a 100644 --- a/plans/config.md +++ b/plans/config.md @@ -51,7 +51,10 @@ matches steady state. Bootstrap fixed points stay on `EmitterConfig`, boot-only, never republished: connection params (`[ch] host/port/user/password/database/secure`), toast store, -`soft_delete`, `[memory] resident_payload_max` / `inline_value_max` (resident +`soft_delete`, pending capture cost controls (`[stream] +pending_max_boundaries_per_xact`, `pending_max_hold_ms`; +[desc_log.md](desc_log.md) Pending capture), `[memory] +resident_payload_max` / `inline_value_max` (resident budget pool + leaf reserve sized at pipeline spawn — [emitter.md](emitter.md) Memory budget; `spill.dir` stays the `--spill-dir` CLI arg), and the source physical replication slot (`[source] slot` → diff --git a/plans/decoder.md b/plans/decoder.md index 03a425b..e85dc5f 100644 --- a/plans/decoder.md +++ b/plans/decoder.md @@ -222,11 +222,11 @@ In-tree decoders ([`src/codecs.rs`](../src/codecs.rs)): Everything else — `jsonb`, range types, arrays (`typcategory='A'`), `tsvector`, vendor types — routes through `ColumnValue::PgPending { type_oid, raw }` carrying on-disk varlena -body. Emitter resolves text form at emit time via -`walshadow_decode_disk(oid, bytea) -> text` SQL call against shadow PG -(`walshadow` extension); falls back to `` placeholder + -`unsupported_values` bump when extension absent. One source of truth -lives on shadow, no per-type codec drift to chase in walshadow itself +body. Emitter resolves text form at emit time through shadow PG's own +`typoutput`, reached over required bridge worker socket. Per-item +`typoutput` errors leave `PgPending` unresolved and preserve on-disk body +(see [oracle.md](oracle.md) § Failure semantics). One source of truth lives +on shadow, no per-type codec drift to chase in walshadow itself ## Replica identity diff --git a/plans/desc_log.md b/plans/desc_log.md index 8dddcf7..8b38e5c 100644 --- a/plans/desc_log.md +++ b/plans/desc_log.md @@ -106,6 +106,51 @@ routine path. Toast rels ('t') capture entries and `Dropped` events only (the retire ledger consumes those); indexes are excluded entirely. +## Pending capture + +Pending capture samples the same relations mid-transaction. At +`wal_level=logical` PG logs `XLOG_XACT_INVALIDATIONS` from every +`CommandCounterIncrement`, and a relation's layout cannot move except at +one, so those records are exactly the sample points. Inside a dirty xact +each becomes a `BoundaryKind::Command` boundary, holding publication the +same way a commit does; capture reads the relations that command's invals +name off the bridge worker's `SCAN` at the parked position, where the +transaction's own catalog rows sit on-page uncommitted +([shadow.md](shadow.md) Bridge worker). Shapes land in `PendingCatalog` +(`src/catalog/pending.rs`), keyed by the tree root the pump knew at +capture, `valid_from` at the boundary or at the generation's smgr marker +when the relation was born in this xact. + +**A pending descriptor is visible only to records of the transaction that +wrote it, and becomes durable only at that transaction's commit.** Nothing +speculative reaches the log, so abort is a map removal: the abort record +names every member the filter drained and their slots die on the pump, +ahead of any later boundary that could promote them. A subxact abort drops +the slots its own xid wrote and leaves the parent's. + +At the commit, the member keys fold under the top and the slots enter the +batch as `Present` entries at their own positions, ahead of the commit +shape — one entry per (relation, command boundary) instead of one per +relation. That is what shrinks the fence: an unproven in-place change +whose relation the timeline covers publishes its `Ambiguity` only over +`[first_touch, first boundary)`, because rows past that boundary have an +exact shape recorded and rows before it predate the transaction's first +`CommandCounterIncrement` — a command sees the catalog as of its own +start, so the predecessor reads them. The stash resolution folds the same +chain per record, so a record inside a covered run decodes under the shape +that run saw rather than the commit-time descriptor. + +Every failure degrades the transaction to commit-time capture, which is +sound: `CaptureAll` (whole-relcache flush or namespace catcache — a full +catalog scan per command is the shape that makes holds expensive), +`CapExceeded` (`pending_max_boundaries_per_xact`), `HoldBudget` +(`pending_max_hold_ms` cumulative), `ReplayMismatch` (shadow was not +parked where the boundary said — unrecoverable, replay cannot rewind), +`QueryError`. A degraded transaction's slots still promote, since each is +an exact shape at an exact position; what degradation costs is the +coverage claim, so its fence stands. A boundary whose inval set names no +user relation skips the hold entirely. + ## Replay-from-log Every boundary appends a batch keyed `captured_at = next_lsn` — a @@ -202,3 +247,15 @@ making stored rfns directly comparable to WAL locators' physical spcOid. capture_all / rels / seconds), `walshadow_desc_events_*`, `walshadow_desc_log_*` gauges + GC counters, `walshadow_desc_lookups_*` by result. Capture time counts inside the boundary-hold duration. + +Pending capture adds `walshadow_pending_captures_total`, +`walshadow_pending_rels_total`, `walshadow_pending_holds_total`, +`walshadow_pending_hold_seconds_total`, +`walshadow_pending_entries_promoted_total`, +`walshadow_pending_entries_dropped_abort_total`, +`walshadow_pending_ambiguities_suppressed_total` and +`walshadow_pending_degraded_total{reason}`. Overlay-scan cost and its +unresolvable-parentage count sit on the bridge families +(`walshadow_bridge_scan_*`), which only pending scans populate — a +committed read passes no transaction, so nothing is left to +misattribute. diff --git a/plans/filter.md b/plans/filter.md index 2a5c213..3a488e8 100644 --- a/plans/filter.md +++ b/plans/filter.md @@ -126,6 +126,16 @@ dirty entry; aborts drain them without holding. Only descriptor-relevant messages dirty (namespace hit, whole-relcache flush, user-rel relcache inval): ANALYZE-rate catcache churn must not hold publication at commit +Same record also bounds: a `BoundaryKind::Command` verdict scoped to that +command's own relcache invals, since a relation the set leaves out did +not change shape there. Its `drain_xid` is the tree root as known at that +point, which for a subxact whose assignment has not arrived is the subxid — +the commit's member list is what folds those keys together +([desc_log.md](desc_log.md) Pending capture). Abort verdicts carry the +drained members for the same reason: the speculative shapes keyed to them +have to die with the tree, on the pump, before a later boundary promotes +them + ## Rewrite path `src/rewrite.rs::noop_replace` takes complete record buffer (header + diff --git a/plans/future/INDEX.md b/plans/future/INDEX.md index 7515172..328f54a 100644 --- a/plans/future/INDEX.md +++ b/plans/future/INDEX.md @@ -6,10 +6,12 @@ surface; promote into `plans/` once built * [TABLESPACES.md](TABLESPACES.md) — source-tablespace correctness: bootstrap page-walk + shadow directory materialization; full-rfn physical identity invariant * [catalog_capture_completeness.md](catalog_capture_completeness.md) — catalog → descriptor dependency taxonomy: capture-all trigger set, pg_type/typname staleness, rename events, toast-spool retire on rotation * [descriptor_timeline.md](descriptor_timeline.md) — per-record layout fidelity inside a dirty xact: hold at `XLOG_XACT_INVALIDATIONS`, shadow-side historic read via pgext, per-command descriptor timeline replacing the commit-time sample +* [custom_rmgr.md](custom_rmgr.md), redo interpose for command-boundary capture: pre-wire arm, custom rmgr callback, pushed arrival, bounded publication gate, crash-safe fallback * [DESTINATIONS.md](DESTINATIONS.md) — N:M ClickHouse destination routing: fan-out/fan-in, per-dest ack accounting, slot-advance tension * [runtime_config_from_pg.md](runtime_config_from_pg.md) — source-PG runtime config: signal channel, net-new knobs, degraded-mode fallback, resolver observability (resolver substrate + per-table opt-in + column overrides in [config.md](../config.md)) * [shadow_schema_export.md](shadow_schema_export.md) — shadow PG as schema-only catalog donor to third-party clusters * [shadow_toast.md](shadow_toast.md) — shadow-backed TOAST chunk store with WAL replay and crash-safe reclamation fencing +* [failover.md](failover.md) — lossless source continuation across descendant PostgreSQL timelines: historic WAL drain, fork-segment verification, shadow handoff, lineage-aware restart * [sync_commit_witness.md](sync_commit_witness.md) — walshadow as RPO=0 durability standby * [two_phase_commit.md](two_phase_commit.md) — `XLOG_XACT_PREPARE` handling and gxid-keyed buffer * [ch_bounce_recovery.md](ch_bounce_recovery.md) — deeper re-emit-from-spill on retry-budget exhaustion diff --git a/plans/future/custom_rmgr.md b/plans/future/custom_rmgr.md new file mode 100644 index 0000000..79675be --- /dev/null +++ b/plans/future/custom_rmgr.md @@ -0,0 +1,486 @@ +# Custom resource manager replay interpose + +Status: future throughput optimization + +Refines redo-interpose phase in +[PLAN_EXTENSION.md](../../PLAN_EXTENSION.md). Depends on +[PLAN_PENDING_XACT.md](../../PLAN_PENDING_XACT.md) command-boundary capture, +one descriptor assembler, durable descriptor-log promotion at commit, and +unified resume floor + +## Objective + +Replace transport starvation at `XLOG_XACT_INVALIDATIONS` boundaries with an +exact redo callback: + +1. arm boundary before filtered bytes reach shadow +2. rewrite only record's resource manager id, preserving length and body +3. let WAL receipt and filtering continue +4. stop startup process inside custom redo callback at boundary +5. push boundary arrival to daemon, capture pending catalog state, publish + pending coverage, then release redo + +Keep current successor-byte hold as fallback. Treat custom redo as latency +optimization, never sole correctness mechanism + +Commit and abort records stay under built-in transaction resource manager. +Their redo changes transaction state and cannot be replaced. Existing commit +hold remains final durability boundary for timeline promotion + +## Why + +Pending capture holds once per dirty transaction command boundary. Current +hold sends boundary record, withholds every successor byte, polls standby +status at 20 ms cadence, captures, then resumes pump. Sequence is exact +because replay cannot advance past bytes shadow never received, but every +boundary stalls: + +- source WAL consumption +- source keepalive handling +- shadow WAL receipt +- filtered archive assembly +- decoder queue ingress + +A transaction with hundreds of DDL commands pays poll and status propagation +hundreds of times. Custom redo removes fixed transport delay and lets WAL +queue behind startup process while catalog capture runs + +P3 improves ingress throughput and liveness, not catalog correctness. Current +hold already pins replay exactly. Stale status can delay release but cannot +release early while successor WAL remains absent + +## Invariants + +1. No decoder record after boundary `L` becomes visible to decode worker until + pending coverage or explicit degradation for `L` is installed +2. No commit drains until pending entries are promoted into durable descriptor + batch or existing fail-closed path wins +3. Resume floor never passes unresolved boundary or uncommitted transaction +4. Filter never emits custom rmgr record unless shadow acknowledged matching + live arm +5. Unarmed custom rmgr replay is a no-op, covering crash recovery and retained + archive replay after original capture +6. Missing, stale, failed, or timed-out interpose state selects byte hold or + pending-capture degradation, never guessed catalog state +7. Ahead WAL, armed boundaries, queued records, and archive publication stay + bounded +8. `ShadowStart::External` never receives custom record by default + +## Scope + +Interpose only `XLOG_XACT_INVALIDATIONS` records emitted at command end under +`wal_level=logical` + +PostgreSQL transaction redo ignores these records. Previous catalog heap +records have already applied when callback starts, while invalidation record +itself has no physical redo effect. Blocking inside callback therefore exposes +catalog page state at command boundary without replacing required PostgreSQL +redo + +Do not interpose: + +- `XLOG_XACT_COMMIT` +- `XLOG_XACT_ABORT` +- prepared transaction records +- assignment records +- clean-xact invalidation records with no pending capture work +- any record sent to shadow lacking negotiated capability + +## Record rewrite + +Preserve: + +- `xl_tot_len` +- `xl_xid` +- `xl_prev` +- `xl_info` +- resource-manager body +- page layout and record alignment + +Replace `xl_rmid` with reserved walshadow custom id, then recompute record CRC +using existing rewrite machinery. Same length preserves every following LSN + +Validate before rewrite: + +- built-in rmgr is transaction +- op is `XLOG_XACT_INVALIDATIONS` +- record has no block references +- parsed body is structurally valid for negotiated PostgreSQL major +- filter classified boundary as pending-capture command + +Filtered manifest keeps original rmgr and info and marks entry +`Interposed`. Archive reader must recover original classification from +manifest. Custom record without matching manifest entry is corruption for +daemon replay, not an ignorable unknown record + +Reserve stable custom rmgr id before default enablement. Experimental id is +development-only + +## Capability and activation + +Register custom rmgr from `_PG_init` whenever module appears in +`shared_preload_libraries`, independent of socket enablement. Retained custom +WAL may need callback even when live interpose is disabled + +Extend `HELLO` with: + +- `WS_CAP_REDO_INTERPOSE` +- registered custom rmgr id +- shared-state protocol version +- maximum arm-ring capacity + +Activate per shadow session only after exact protocol, projection, PostgreSQL +major, custom id, and capability match + +For daemon-owned shadow: + +- use interpose after successful session open +- fall back to current byte hold on any arm failure +- keep module preloaded until every retained custom record ages out + +For external shadow: + +- default to no rewrite +- require explicit operator opt-in plus successful capability negotiation +- never infer support from socket reachability alone + +Preflight refuses config removing module while archive or manifest reports +retained `Interposed` entries + +## Shared memory + +Request fixed-size addin shared memory during preload. Keep one session header +and bounded ring of boundary slots + +Session header: + +```text +protocol +session_generation +owner_nonce +state: closed | open | draining +head +tail +capacity +worker_proc +startup_proc +``` + +Boundary slot: + +```text +generation +timeline +record_start_lsn +record_end_lsn +xid +state: free | armed | reached | scanning | released | aborted | timed_out +result +``` + +Use generation plus exact LSN to prevent ABA after reconnect, ring reuse, or +WAL resend. Protect state transitions with extension LWLock or spinlock, wake +processes through latches or condition variables. Never sleep while holding +state lock + +One startup process consumes slots in LSN order. Multiple slots let pump arm +future command boundaries while replay waits at first. Full ring applies +backpressure to pump + +## Control channel + +Use dedicated bridge connection for bidirectional interpose control. Existing +request connections continue serving `SCAN`, `DECODE`, and `REPLAY_LSN` + +Control messages: + +| direction | message | purpose | +|---|---|---| +| daemon → worker | `INTERPOSE_OPEN` | claim single session, establish generation | +| daemon → worker | `INTERPOSE_ARM` | append boundary slot before wire publication | +| worker → daemon | `INTERPOSE_REACHED` | push startup arrival, no poll | +| daemon → worker | `INTERPOSE_RELEASE` | allow successful boundary callback to return | +| daemon → worker | `INTERPOSE_ABORT` | allow failed or degraded boundary callback to return | +| either | `INTERPOSE_CLOSE` | retire session and wake waiter | + +Tag every frame with session and boundary generation. Dedicated channel avoids +unsolicited `REACHED` frames interleaving with request-response bridge traffic + +Worker event loop watches control socket plus latch set by redo callback. +Callback only mutates shared state and wakes worker. It performs no socket I/O, +catalog access, allocation-heavy work, or daemon protocol parsing + +Control disconnect marks live slots aborted and wakes startup. Worker restart +reattaches to shared state. Postmaster restart clears volatile state, making +retained records unarmed no-ops + +## Pre-wire arm + +Arm must complete before rewritten record reaches `RecordBytesSink`. Current +record sink callback runs after wire delivery, too late for this guarantee + +Add pre-wire interposer seam in `WalStream::drain_records` after filter verdict +and before in-place rewrite or `on_wire_chunk`: + +```text +parse original record +classify command boundary +request INTERPOSE_ARM + acknowledged: + rewrite rmid + CRC + mark manifest Interposed + unavailable, full, stale, or rejected: + keep original xact record + select current byte hold +publish wire bytes +enqueue original parsed record for daemon processing +``` + +Never rewrite first and arm later. Never retry arm after any byte from record +may have reached shadow + +If source disconnects after arm but before wire publication, close or cancel +slot only when byte sink proves record was not published. Otherwise leave slot +for exact-LSN match or session cleanup + +## Redo callback + +On custom record: + +1. validate info, xid, record start, and record end against ring head +2. if no exact armed slot exists, return with no effect +3. transition `armed → reached` +4. publish startup process identity +5. wake bridge worker +6. wait interruptibly for `released`, `aborted`, session loss, shutdown, or + deadline +7. clear process identity and return + +Unarmed no-op is required: + +- already captured record may replay after restart +- startup may encounter retained archive before bridge worker starts +- worker starts only at consistent state because catalog scan needs database + connection +- postmaster crash clears shared arm state + +Callback timeout returns after marking slot `timed_out`. It must not wedge +recovery indefinitely. Daemon-side publication gate makes fail-open replay +safe: scan loses exact position, command boundary degrades, and successor +records remain unpublished until fallback verdict exists + +Unknown custom rmgr remains hard operational failure. Keep module preload +dependency until retained custom WAL disappears + +## Replay position + +Current `SCAN` reads `GetXLogReplayRecPtr()`. That reports +`lastReplayedEndRecPtr`, updated only after redo callback returns, so it points +to previous record while interpose is active + +PostgreSQL sets `replayEndRecPtr` before calling rmgr redo. +`GetCurrentReplayRecPtr()` therefore reports interposed record end while +callback waits + +Do not globally replace scan checks with `GetCurrentReplayRecPtr()`. Another +rmgr may be executing outside interpose + +Interposed scan validates: + +- shared slot is `reached` or `scanning` +- session and boundary generation match request +- expected LSN equals slot `record_end_lsn` +- `GetCurrentReplayRecPtr()` equals expected LSN +- slot remains same generation before and after scan +- `GetXLogReplayRecPtr()` has not advanced through boundary + +Normal pinned scan keeps current `GetXLogReplayRecPtr()` checks + +## Capture and publication + +Move command-boundary capture behind bounded queue rather than blocking pump +thread + +For interposed boundary: + +1. pump arms, rewrites, sends bytes, and enqueues original parsed record +2. startup reaches custom callback and pushes `REACHED` +3. capture lane waits for matching push +4. worker scans projections while callback holds redo +5. daemon assembles descriptors through existing Rust assembler +6. daemon installs pending timeline entry or explicit degradation +7. daemon releases callback +8. decoder publication gate forwards boundary, then queued successor records + +Pending entries remain speculative until transaction outcome: + +- commit folds entries into durable descriptor batch before commit publication +- abort drops entries +- restart before commit replays from unified floor and may degrade if shadow + already passed command boundary + +Do not wait for descriptor-log fsync at every command boundary. Commit remains +durability point. Requiring per-command fsync would exchange poll stall for +storage stall + +Archive may write and fsync bytes ahead of command boundary, but resume floor, +decoder ack, retention cut, and commit publication cannot pass unresolved +transaction. Existing unified floor remains recovery anchor + +## Bounded flow + +Keep independent bounds: + +- shared arm-ring slots +- pump-to-capture record channel +- queued decoder records behind earliest unresolved boundary +- filtered archive bytes not yet eligible for resume-floor advancement +- shadow `pg_wal` growth while redo waits + +Ring or channel saturation parks pump and lets source slot retain WAL. P3 +removes fixed per-boundary stop, not all backpressure + +One long transaction can place substantial DML between command boundary and +commit. Spill queued records through existing transaction-buffer machinery +rather than grow memory without bound + +## Failure semantics + +| event | action | +|---|---| +| capability absent or `OPEN` fails | never rewrite, use byte hold | +| `ARM` rejected before wire | keep original record, use byte hold | +| daemon dies after arm, before wire | session cleanup aborts slot | +| daemon dies after wire, before scan | callback aborts or times out; restart reprocesses at floor, pending capture degrades if replay moved | +| worker dies before callback | arm remains in shmem; restarted worker resumes session or timeout aborts | +| worker dies during scan | request fails, daemon installs degradation, callback aborts or times out | +| control socket closes while callback waits | mark session draining, abort slots, wake startup | +| postmaster crashes while callback waits | shared state disappears; replayed custom record is unarmed no-op; daemon loses bridge session and degrades unresolved boundary | +| catalog lock timeout | scan errors, daemon installs degradation, sends `ABORT` | +| replay position differs | reject scan, install `ReplayMismatch` degradation, send `ABORT` | +| stale custom record during archive replay | unarmed callback returns immediately | +| ring full | stop arming until head retires, bounded pump backpressure | +| module missing with retained custom WAL | startup fails; operator restores preload or rebuilds shadow | + +Failure may cost pending coverage and throughput. It must not publish descriptor +from wrong replay position or strand startup indefinitely + +## Diagnostics + +Stock `pg_waldump` supports custom rmgr ids on PostgreSQL 16 and later. It +prints numeric `customNNN` name without walshadow-specific description. +Filtered segments remain structurally readable but lose transaction +invalidation description + +Manifest tooling should render `Interposed` entry as original transaction +invalidation record plus custom id and boundary generation where available + +Expose current session and ring state in worker logs and daemon status without +printing catalog row data + +## Metrics + +- `custom_rmgr_session_up` +- `custom_rmgr_armed_total` +- `custom_rmgr_reached_total` +- `custom_rmgr_released_total` +- `custom_rmgr_aborted_total{reason}` +- `custom_rmgr_unarmed_replay_total` +- `custom_rmgr_wait_seconds` +- `custom_rmgr_ring_depth` +- `custom_rmgr_ring_full_total` +- `custom_rmgr_scan_errors_total{reason}` +- `custom_rmgr_fallback_total{reason}` +- `custom_rmgr_ahead_bytes` +- `custom_rmgr_callback_timeouts_total` + +Compare against: + +- `pending_holds` +- `pending_hold_nanos` +- source keepalive failures +- boundary capture latency +- pending degradation rate + +Expected result after default enablement: command-boundary byte holds trend to +zero on daemon-owned shadow, while commit holds remain + +## Phases + +### P0: direct replay read + +Use bridge `REPLAY_LSN` for current byte hold instead of forced standby-status +round trip. Keep successor withholding and poll. This is independent, +low-risk latency reduction + +### P1: protocol and shared state + +Register custom rmgr, add capability handshake, control channel, shared ring, +generation checks, push notification, and callback timeout. Exercise callback +against synthetic custom records without enabling filter rewrite + +### P2: pre-wire rewrite and fallback + +Add pre-wire arm seam, `Interposed` manifest kind, CRC rewrite, archive-reader +interpretation, and exact fallback to original record plus byte hold on every +unarmed path. Keep feature off by default + +### P3: queued capture + +Move command-boundary capture behind bounded publication gate. Install pending +coverage before successor decoder records, preserve commit-time durability, +and add bounded spill/backpressure + +### P4: fault rollout + +Enable opt-in on daemon-owned shadows after cross-major and crash matrix passes. +Compare hold metrics and degradation rate against byte-hold baseline. Make +default only when interposed path shows no correctness-only failure mode and +fallback remains continuously exercised + +## Acceptance + +- capability mismatch emits original xact record byte-for-byte and takes + current hold +- acknowledged arm precedes first rewritten wire byte +- rewritten record preserves length, xid, prev pointer, info, body, alignment, + and valid CRC +- shadow with module replays custom record; shadow without live arm treats it + as no-op +- callback reports exact boundary through shared generation and + `GetCurrentReplayRecPtr` +- normal `SCAN` continues using `GetXLogReplayRecPtr` +- successor WAL reaches shadow while callback waits, but successor decoder + record does not pass publication gate +- pending coverage publishes before first post-boundary decoder record +- commit promotion remains durable before commit drain +- abort drops every speculative command entry +- two hundred command boundaries reuse bounded ring without status polling or + unbounded memory +- ring saturation backpressures pump without losing arm or record order +- catalog `VACUUM FULL` lock conflict hits worker lock timeout, degrades, and + releases callback +- worker kill during scan does not wedge startup past callback deadline +- daemon kill at every transition restarts from floor with either captured + timeline or explicit degradation +- postmaster kill during callback replays retained custom record unarmed and + daemon refuses stale scan +- old interposed archive replays after feature disable while module remains + preloaded +- external shadow receives no custom record without explicit opt-in and + negotiated capability +- stock `pg_waldump` walks filtered segment and labels custom record numerically +- PostgreSQL 16, 17, and 18 pass same protocol and recovery matrix + +## Promotion criteria + +1. pending command-boundary capture and commit promotion are landed +2. byte-hold path has production metrics establishing boundary frequency and + stall cost +3. every acceptance case above runs in CI or dedicated fault suite +4. custom rmgr id is stable +5. retained-WAL preload dependency has preflight and operator recovery path +6. fallback byte hold remains available per boundary + +Do not fold into pending-capture correctness work. Do not defer after these +criteria hold and metrics show command-boundary stall is material diff --git a/plans/future/failover.md b/plans/future/failover.md new file mode 100644 index 0000000..f89c54d --- /dev/null +++ b/plans/future/failover.md @@ -0,0 +1,620 @@ +# Source timeline failover + +Keep CDC live when source endpoint moves to a promoted PostgreSQL +primary on a descendant timeline while walshadow still reads an +ancestor timeline. Continue from durable floor, consume remaining +ancestor WAL through fork point, switch timelines, and resume without +`--ignore-cursor`, rebootstrap, or skipped WAL + +This plan covers source-consumer continuity. It does not make +walshadow an HA orchestrator or durability witness. WAL relay into a +lagging full PostgreSQL standby remains +[sync_commit_witness.md](sync_commit_witness.md) + +## Target scenario + +``` +old primary, TLI 4 promoted primary, TLI 5 + + WAL through A ────────────────┐ + ├── fork at F ── TLI 5 WAL ── H +walshadow durable floor R ───────────┘ + R < F < H +``` + +Walshadow reconnects at `R` while `IDENTIFY_SYSTEM` reports timeline +5. Timeline 4 belongs to timeline 5 history. PostgreSQL can serve +`START_REPLICATION ... R TIMELINE 4`, stop at `F`, report timeline 5 +and its start position, then serve timeline 5 + +Support both ways transition becomes visible: + +1. Existing connection serves a standby that gets promoted. Walsender + turns requested timeline historic, reaches fork point, then ends + COPY with next-timeline result +2. Connection drops and HA endpoint resolves to promoted primary. + `IDENTIFY_SYSTEM` reports newer timeline before walshadow resumes + ancestor stream + +Walk more than one generation. A consumer on timeline 2 may reconnect +to a primary on timeline 5 and cross `2 → 3 → 5` one reported +transition at a time + +## Scope + +Automatic continuation requires all of: + +- unchanged PostgreSQL system identifier +- stored timeline present in live primary's history +- resume position on that ancestral branch, at or before its fork +- required WAL retained by source slot, `pg_wal`, or configured archive +- promoted primary carrying configured physical slot, when slot mode + is enabled +- no externally visible walshadow publication from abandoned branch + beyond fork point + +Reject rather than guess when any proof fails + +Out of scope: + +- leader election, old-primary fencing, DNS or proxy orchestration +- switching to unrelated system identifier +- accepting sibling timeline when stored branch is absent from live + history +- compensating ClickHouse data already published from an abandoned + branch beyond fork point +- manufacturing WAL missing from source and archive +- promoting schema-only shadow into application primary +- preserving prepared transactions before + [two_phase_commit.md](two_phase_commit.md) lands + +## Current behavior + +Current paths fail closed, but cannot continue: + +- `SourceFeed::reconnect` rejects any `IDENTIFY_SYSTEM` timeline + mismatch before issuing `START_REPLICATION` +- `SourceFeed::next_chunk` maps backend `CopyDone` to `Ok(None)`; + daemon treats it as terminal shutdown and never reads + `next_tli` / `next_tli_startpos` +- `start_physical_replication` requires CopyBoth. PostgreSQL may return + immediate next-timeline result when requested historic timeline + already ends at requested position +- `SourceRecovery`, `WalStream`, archive lookup, segment naming, + `ShadowStreamState`, and manifest writer retain boot timeline +- shadow-facing walsender exposes empty timeline history and cannot + finish one timeline into next +- manifest and descriptor-log identity treat timeline change as + foreign source; `--ignore-cursor` adopts live timeline by discarding + continuity +- no transition fence retires uncommitted xid-scoped state abandoned + at fork + +Staged pending-catalog timeline work is orthogonal. Its "timeline" +means relation descriptor versions inside one transaction, not +PostgreSQL WAL `TimeLineID` + +## Correctness invariants + +### Lineage + +System identifier owns artifacts. Timeline identifies selected branch +through those artifacts + +- system identifier mismatch stays fatal +- same system identifier is necessary, not sufficient +- live history must prove stored timeline is ancestor +- history entry must prove resume LSN belongs to stored timeline +- every transition must increase timeline ID and match server-reported + switch LSN +- sibling branch or backward timeline report stays fatal + +### Byte and record continuity + +- feed bytes to `WalStream` exactly once in increasing branch order +- accept repeated prefix bytes only inside transition verifier, never + send them through filter or decoder twice +- emit no complete record from abandoned ancestor suffix beyond fork +- require last dispatched record end at or below switch LSN +- discard only partial, undispatched record bytes beyond switch LSN +- poison stream when server reports fork behind dispatched record + +PostgreSQL can have `sentPtr > switch_lsn` when promotion catches a +partially received WAL record. Such suffix cannot replay on new branch. +Transition logic must truncate it before accepting descendant bytes + +### Durable progress + +Timeline associated with durable resume floor, not furthest byte +received. Pipeline may already ingest timeline 5 while emitter floor +still belongs to timeline 4 + +- persist `(floor_tli, floor_lsn)` atomically +- restart requests `floor_tli` at `floor_lsn`, even when live primary + reports newer timeline +- advance `floor_tli` only after floor crosses corresponding fork +- retain transition metadata until every durable floor passes it +- keep GC cutoff at or below same floor on same branch + +### External publication + +Automatic switch is safe only while live branch fork is not behind +externally durable publication + +- reject when `emitter_ack_lsn > switch_lsn` on abandoned branch +- audit schema-event acknowledgment against same rule +- reject when descriptor history or other durable side effects beyond + fork cannot be rolled back without touching ClickHouse +- allow received but undispatched partial bytes past fork to truncate +- allow uncommitted buffered transactions at or before fork to abandon + +Target lagging case has resume and publication frontiers below fork, +so no compensation is needed + +### Shadow continuity + +- shadow sees same system identifier and selected timeline chain +- history file content matches source history +- shadow receives all filtered WAL through ancestor fork before + descendant WAL +- fork-containing segment has correct descendant timeline identity +- catalog boundary on descendant timeline cannot pass until shadow + replay follows transition +- shadow reconnect at any point can recover from filtered archive plus + timeline history without byte gap + +## Source replication protocol + +### Events + +Replace `Option` end signal with explicit protocol outcome: + +```rust +enum SourceEvent<'a> { + Wal(WalChunk<'a>), + TimelineEnd { + finished_tli: u32, + next_tli: u32, + switch_lsn: u64, + }, + Shutdown, +} +``` + +Distinguish controlled server shutdown from historic-timeline end. +`CopyDone` alone does not make distinction; finish COPY exchange and +parse following response: + +- one row with `next_tli`, `next_tli_startpos` means timeline end +- CommandComplete without row means controlled stream termination +- malformed row, missing command completion, or protocol regression + poisons connection + +When backend sends `CopyDone`: + +1. send frontend `CopyDone` +2. read RowDescription/DataRow when present +3. validate exactly one row and at least two fields +4. parse `next_tli` and PostgreSQL LSN text +5. consume CommandComplete and ReadyForQuery +6. return `TimelineEnd` + +`START_REPLICATION` needs matching start outcome: + +```rust +enum StartOutcome { + Streaming, + TimelineEnd { + next_tli: u32, + switch_lsn: u64, + }, +} +``` + +Historic request at or after fork may skip CopyBoth and return result +immediately. Feed this through same transition path + +### Reconnect + +Change reconnect contract from "live timeline must equal requested" +to: + +1. connect and run `IDENTIFY_SYSTEM` +2. require system identifier equality with manifest +3. if live timeline equals requested, start normally +4. if live timeline is newer, fetch `TIMELINE_HISTORY ` +5. parse history and prove requested timeline ancestry at resume LSN +6. issue `START_REPLICATION` using requested timeline, not live timeline + +Let PostgreSQL walsender enforce branch membership again. Client-side +history validation protects persistent artifacts before stream starts; +server-side validation protects each replication request + +Persist exact history bytes returned by source. Do not synthesize +history from timeline numbers + +### Transition coordinator + +Add one owner for mutable source branch state: + +```rust +struct TimelineCursor { + system_id: String, + stream_tli: u32, + next_lsn: u64, + history: TimelineHistory, +} +``` + +`SourceRecovery` reads cursor instead of storing boot timeline. +Archive lookup, reconnect, status logging, manifest snapshots, and +metrics read same owner + +On `TimelineEnd`: + +1. stop source ingestion +2. validate `finished_tli == stream_tli` +3. validate `next_tli > finished_tli` +4. validate switch LSN against history and publication frontier +5. establish ordered pipeline transition fence +6. prepare fork segment for descendant bytes +7. install source history and downstream shadow transition +8. change `stream_tli` +9. request descendant timeline +10. resume ingestion after prefix validation + +Repeat until stream reaches live timeline + +## Fork-segment handling + +Always restart descendant timeline from fork segment start, matching +PostgreSQL `pg_receivewal` behavior: + +```text +segment_start = align_down(switch_lsn) +``` + +Do not pass repeated prefix through `WalStream`. Transition adapter: + +1. retain current segment prefix already consumed on ancestor +2. truncate undispatched ancestor suffix after switch LSN +3. request descendant from `segment_start` +4. compare repeated `[segment_start, switch_lsn)` bytes against retained + prefix +5. fail on any mismatch +6. suppress matching prefix +7. feed bytes from `switch_lsn` onward through normal `WalStream::push` + +This provides three properties: + +- detects corrupt or sibling branch despite matching system ID +- avoids double classification and duplicate xid-buffer mutations +- builds complete descendant fork segment using verified common prefix + +`WalStream` needs `transition_timeline(next_tli, switch_lsn)`: + +- require `dispatched_lsn <= switch_lsn <= next_lsn` +- truncate only walker state beyond switch LSN +- clear partial-record continuation state +- keep filter state established by complete records before fork +- change segment identity before fork segment seals +- preserve cumulative metrics + +Segments fully before fork retain ancestor filename. Fork-containing +segment seals under descendant timeline because prefix is byte-equal +and suffix belongs to descendant. Persist source history beside +filtered archive so PostgreSQL recovery can select it + +Test multiple switches inside one 16 MiB segment. Final segment name +must use newest selected timeline while each suppressed prefix matches +every intermediate branch + +## Transaction-state fence + +Timeline transition is ordered record-stream control, not direct +mutation racing decoder worker. Add control item after all complete +ancestor records and wait for worker acknowledgment + +At fence: + +- finish every commit or abort record dispatched before switch +- abandon unresolved ordinary transactions from ancestor +- remove their raw heap records, toast chunks, and resident/spill + accounting from `XactBuffer` +- clear `PendingCatalog` speculative slots +- clear dirty transaction tree and unresolved subtransaction mapping +- clear smgr markers owned by abandoned transactions +- reset pending boundary holds +- audit catalog tracker for uncommitted pg_class observations; reseed + conservatively from new source or caught-up shadow +- advance resume-safe accounting past discarded uncommitted records + +No uncommitted ordinary transaction survives source promotion. Prepared +transactions differ, PostgreSQL can preserve and later finish them on +new primary. Keep failover unsupported when prepared state is present +until gxid-keyed buffering from +[two_phase_commit.md](two_phase_commit.md) defines transition behavior + +Descriptor log receives only committed shapes, so pending slots drop +without durable compensation. If any durable batch lies beyond reported +fork, reject automatic switch + +## Manifest and descriptor history + +### Manifest + +Bump schema. Separate ownership from branch cursor: + +```toml +[source] +system_id = 7334001234567890123 + +[wal] +floor_timeline = 4 +stream_timeline = 5 + +[lsn] +floor = "0/6A000000" +source_received = "0/6B123456" +``` + +Exact field layout may keep current top-level `floor`; invariant is +atomic pairing of floor LSN and floor timeline + +Store discovered timeline transitions or history-file references so +restart and archive recovery can resolve `timeline_at(floor)`. Keep +`stream_timeline` diagnostic; never use it in place of +`floor_timeline` + +Boot: + +1. load system ID, floor timeline, floor LSN, and known history +2. identify live source +3. reject system ID mismatch +4. fetch live history when timeline differs +5. prove floor timeline ancestry and floor membership +6. start at persisted floor timeline and walk forward + +Remove `--ignore-cursor` requirement for valid descendant. Preserve +flag as explicit rebaseline for unprovable or intentionally discarded +state + +### Descriptor log + +Timeline cannot remain scalar foreign-source identity. Bind log to: + +- PostgreSQL major +- system identifier +- database OID +- WAL segment size +- selected lineage through each covered LSN + +Record timeline transitions durably in descriptor-log metadata or +shared manifest history. On open, prove every durable descriptor batch +belongs to live branch. Reject log whose covered head extends beyond +fork onto sibling branch + +Migration from existing format can treat header timeline as lineage +origin. Accept newer live timeline only when source history contains +origin through log's covered range + +## Shadow-facing replication + +Extend wal-rus server surface from single timeline to supplied history: + +- dynamic `IDENTIFY_SYSTEM` current timeline +- exact `TIMELINE_HISTORY ` filename and bytes +- per-connection requested timeline +- historic stream cutoff +- server `CopyDone` +- next-timeline result after client `CopyDone` +- immediate result when requested position already reaches fork + +Source transition coordinator publishes history before advertising new +timeline. Existing shadow connection finishes ancestor stream at fork, +then follows descendant using normal PostgreSQL walreceiver behavior + +Keep filtered bytes available for lagging shadow: + +- sealed ancestor segments before fork +- descendant fork segment with verified common prefix +- descendant segments after fork +- history files required to select them + +Disconnect fallback may remain for broken clients, but normal path must +complete PostgreSQL timeline protocol. Blind socket close risks +walreceiver repeatedly requesting old timeline without learning next +timeline + +Boundary gate remains final safety check. Do not capture descendant +catalog state until shadow reports replay on descendant branch through +boundary LSN + +## Slot and archive requirements + +Slot mode: + +- require configured slot on promoted primary +- verify slot type is physical +- verify slot can serve stored floor, including ancestor history +- prefer PG 17 failover slots synchronized to standby +- support pre-PG-17 operator-created slot only with explicit position + validation +- never create missing failover slot at current head and call + continuation successful + +Slotless mode: + +- request ancestor WAL from promoted primary while retained +- fall back to archive by timeline-aware segment name +- fetch and validate history files from archive +- retain current missing-WAL failure when neither source nor archive + covers floor + +Archive recovery must advance timeline rather than treating missing next +ancestor segment as generic archive exhaustion. Resolve absence against +known switchpoint: + +- before switchpoint, missing segment is real gap +- at switchpoint, select descendant fork segment +- after switchpoint, fetch descendant timeline + +## Failure policy + +Expose typed terminal reasons: + +- `system_id_changed` +- `timeline_not_descendant` +- `resume_past_fork` +- `published_past_fork` +- `history_missing` +- `history_malformed` +- `fork_prefix_mismatch` +- `slot_missing` +- `slot_too_new` +- `wal_missing` +- `shadow_transition_failed` +- `prepared_xact_present` + +Keep retry only for connection and transient storage errors. Lineage, +prefix, publication, and slot-position failures require operator action + +Never fall back from failed lineage proof to live head automatically + +## Observability + +Add: + +- `walshadow_source_timeline` +- `walshadow_floor_timeline` +- `walshadow_timeline_switches_total` +- `walshadow_timeline_switch_failures_total{reason}` +- `walshadow_timeline_transition_seconds` +- `walshadow_timeline_switch_lsn` diagnostic gauge or structured log +- `walshadow_timeline_prefix_bytes_verified_total` +- `walshadow_timeline_abandoned_xacts_total` +- `walshadow_timeline_abandoned_bytes_total` +- shadow timeline and replay timeline in status snapshot + +Log system ID, old timeline, new timeline, switch LSN, resume LSN, +publication frontier, slot, and history source at every transition. +Never log credentials or full connection strings + +## Tests + +### Pure protocol + +- parse historic end row after backend `CopyDone` +- distinguish controlled shutdown without row +- handle immediate end result without CopyBoth +- reject missing field, malformed LSN, non-increasing timeline, and + missing CommandComplete +- parse PostgreSQL history with comments, multiple ancestors, and + non-consecutive timeline IDs +- prove ancestor membership at positions before, at, and after fork + +### WalStream + +- switch at segment boundary +- switch mid-segment +- discard ancestor partial record beyond fork +- reject complete dispatched record beyond fork +- verify repeated descendant prefix and suppress redispatch +- reject one-byte prefix mismatch +- walk multiple timelines inside same segment +- seal fork segment under descendant name +- preserve filter statistics and catalog tracker state from committed + prefix + +### Transaction state + +- committed transaction before fork drains normally +- ordinary uncommitted transaction at fork drops raw and toast state +- pending descriptor slots drop +- subxact and dirty-tree state clear +- spill accounting returns to zero +- xid reuse on descendant cannot see ancestor state +- prepared transaction causes explicit unsupported failure + +### Persistence + +Crash and restart at each edge: + +1. before ancestor CopyDone +2. after next-timeline result, before transition metadata write +3. after history persistence, before descendant request +4. while verifying fork prefix +5. after source stream changes timeline while durable floor remains + ancestor +6. after floor crosses fork +7. after shadow changes timeline, before next manifest cadence + +Every restart resumes `(floor_tli, floor_lsn)` and reaches same output + +### Real PostgreSQL + +Build primary plus streaming standby fixture: + +- pause walshadow behind planned promotion point +- promote standby and redirect source endpoint +- verify old timeline drains to fork, new timeline continues, and + ClickHouse reaches expected rows +- promote server serving existing connection, verify CopyDone result + path without endpoint change +- switch twice before walshadow catches up +- force fork in middle of WAL segment +- leave ordinary transaction open across promotion +- restart walshadow before and after fork +- verify shadow recovery reaches descendant timeline and catalog DDL + after promotion applies +- run with synchronized failover slot +- verify missing or too-new slot fails with typed reason +- verify same-system sibling timeline rejects +- verify unrelated system identifier rejects +- verify source or archive WAL gap rejects without cursor advance + +Assert: + +- no manual `--ignore-cursor` +- no rebootstrap +- no duplicate decoder-side state mutation +- no committed source row skipped +- no abandoned transaction emitted +- manifest floor and timeline pair stay crash-safe +- descriptor log remains usable after restart +- filtered segment names and history files match PostgreSQL recovery + expectations + +## Landing sequence + +1. Add history parser and source protocol outcomes, keep current + timeline mismatch rejection +2. Add lineage-aware manifest and descriptor-log migration +3. Add fork-prefix verifier and `WalStream` transition primitive +4. Add ordered transaction-state abandonment fence +5. Make source recovery and archive lookup timeline-aware +6. Add shadow-facing history and end-of-timeline protocol +7. Wire coordinator, metrics, and typed failures +8. Enable automatic descendant continuation after real-PG crash matrix + passes +9. Promote behavior into `plans/source.md`, `plans/ops.md`, and + `plans/shadow.md`; reduce source-primary section in `risks.md` to + remaining deployment constraints + +Keep existing fail-closed behavior until all source, persistence, and +shadow pieces compose. Partial support that advances source timeline +without moving durable floor and shadow branch is unsound + +## Acceptance + +Feature is complete when: + +- walshadow behind fork follows promoted descendant automatically +- same-connection promotion and HA-endpoint reconnect both work +- restart from ancestor floor after live source advances works +- shadow follows identical timeline chain without catalog replay stall +- valid descendant preserves manifest, descriptor log, and CH progress +- missing lineage, missing WAL, unsafe slot, or publication past fork + fails before cursor adoption +- full test matrix covers segment, transaction, crash, slot, and + multiple-timeline boundaries diff --git a/plans/future/risks.md b/plans/future/risks.md index 67fedaa..32f2ac4 100644 --- a/plans/future/risks.md +++ b/plans/future/risks.md @@ -95,7 +95,9 @@ the operator's tolerance and parallelism doesn't close the gap Walshadow's physical slot lives on source primary. Source loss loses the slot; promoting source's standby loses walshadow's WAL -position +position. Lossless continuation across a descendant timeline is +planned in [failover.md](failover.md); current implementation still +requires one of options below Two operator options (overview.md pitfall #9): diff --git a/plans/ops.md b/plans/ops.md index cd68fbd..be134a7 100644 --- a/plans/ops.md +++ b/plans/ops.md @@ -81,6 +81,18 @@ Inventory by category: - `walshadow_emitter_{rows,blocks,xacts,unsupported_relations}_total` - `walshadow_decode_{resolved,fallback_raw,errors}_total` (oracle path, see [shadow.md](shadow.md)) +- bridge worker ([oracle.md](oracle.md)): `walshadow_bridge_up` gauge, + 1 while its last transport attempt answered and 0 after failure; + `walshadow_bridge_{requests,errors}_total{op}` and + `walshadow_bridge_request_seconds_total{op}` over + `op=hello|decode|scan|replay_lsn`; + `walshadow_bridge_reconnects_total` redials after a worker exit; + `walshadow_bridge_scan_{rows,subtrans_mismatch,replay_moved}_total` + over catalog scans, where `replay_moved` counts reads that found + shadow replay off the position they pinned — nonzero away from a + boundary hold is expected and answers off SQL, nonzero during capture + is not; `walshadow_bridge_decode_item{s,_errors}_total` columns sent + for `typoutput` rendering and the ones that raised - commit-time stash ([xact.md](xact.md)): `walshadow_raw_stash_records_total{kind=dirty|marker,op}` admissions, `walshadow_raw_stash_deferred_total`, diff --git a/plans/oracle.md b/plans/oracle.md index 1c2a6b6..0ceb014 100644 --- a/plans/oracle.md +++ b/plans/oracle.md @@ -10,8 +10,8 @@ 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 shadow-PG bridge calling same `typoutput` PG itself would -call +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 @@ -36,58 +36,98 @@ Why these: disambiguation lives at type-OID level not body bytes (on-disk vs wire confusion surfaced here historically) -## Extension-routed Tier 3 +## Bridge-routed Tier 3 `jsonb`, arrays, `tsvector`, ranges, domains. Heap decoder emits [`ColumnValue::PgPending { type_oid, raw }`](../src/heap_decoder.rs); -[`resolve_pending_tuple`](../src/oracle.rs) walks tuple columns, calls -`walshadow_decode_disk(oid, bytea) -> text` on shadow, swaps `PgPending` -for `Text` on success +[`resolve_pending_tuple`](../src/oracle.rs) 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 -```sql -SELECT walshadow_decode_disk($1::oid, $2::bytea) -``` +Two alternatives considered (insert + select round-trip; +`SELECT $1::bytea::::text`) require reconstructing wire format from +on-disk format — same codec work the worker elides -Extension is **optional**. Two alternatives considered (insert + select -round-trip; `SELECT $1::bytea::::text`) require reconstructing -wire format from on-disk format — same codec work the extension elides +## walshadow PG module -## walshadow PG extension +Lives at [`pgext/`](../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`](../pgext/walshadow.h) for socket protocol definitions -Lives at [`pgext/`](../pgext/), built via PGXS. Single function: - -```sql -walshadow_decode_disk(typoid oid, raw bytea) RETURNS text -STRICT IMMUTABLE -``` - -Reconstructs Datum from on-disk bytes per typlen / typbyval, runs -`OidOutputFunctionCall` on type's `typoutput`. Four branches in -[`pgext/walshadow.c`](../pgext/walshadow.c) — varlena / cstring / -typbyval fixed / fixed by-ref +`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/walshadow.c`](../pgext/walshadow.c) — C function (~125 LOC) -- [`pgext/walshadow.control`](../pgext/walshadow.control) — extension - metadata, `default_version = '0.1'`, `relocatable = true` -- [`pgext/walshadow--0.1.sql`](../pgext/walshadow--0.1.sql) — DDL - declaring C function `STRICT IMMUTABLE` -- [`pgext/Makefile`](../pgext/Makefile) — PGXS-driven, `REGRESS = - walshadow` for pg_regress - -Installed into **shadow PG**; stays shadow-only. - -## Absence semantics - -Resolver short-circuits `Ok(None)` when `probe_extension` returned -false at connect (or reconnect). `stats.fallback_raw` bumps, `PgPending` -stays put, emitter's `encode_value` calls `append_string_bytes(raw)` -so on-disk body lands verbatim in CH. No -failure, no operator action. `CREATE EXTENSION walshadow` on shadow -followed by daemon reconnect flips `has_extension=true` and text -rendering resumes. Stats surface as `walshadow_decode_{resolved, -fallback_raw,errors}_total` +- [`pgext/decode.c`](../pgext/decode.c) — on-disk Datum → `typoutput` text +- [`pgext/overlay.c`](../pgext/overlay.c) — `SnapshotAny` catalog projections +- [`pgext/worker.c`](../pgext/worker.c) — worker registration, socket, framing +- [`pgext/Makefile`](../pgext/Makefile) — PGXS-driven, `MODULE_big` only + +Loaded into **shadow PG**; stays shadow-only. + +## Decode environment + +`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 + +## Catalog reads + +`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](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 + +## Failure semantics + +Daemon requires worker at startup. `--bridge-socket` defaults to +`/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` ## Pinning shadow locale @@ -100,5 +140,4 @@ See [shadow.md](shadow.md) for bootstrap surface - [decoder.md](decoder.md) — `ColumnValue::PgPending` dispatch + Tier 3 routing through `heap_decoder` -- [shadow.md](shadow.md) — extension install site, - `try_load_oracle_extension`, lc_* pinning +- [shadow.md](shadow.md) — where the worker's GUCs get written, lc_* pinning diff --git a/plans/overview.md b/plans/overview.md index c62fcaa..6d95b9a 100644 --- a/plans/overview.md +++ b/plans/overview.md @@ -113,9 +113,9 @@ Component docs live alongside this overview: `manifest.toml` (six LSNs + resolved floor + source identity), durable TOAST retirement ledger, per-xact `commit_lsn` carrier, slot advance on `min(shadow_replay, emitter_ack)` -- [oracle.md](oracle.md) — PgPending resolver: walshadow PG extension - (`pgext/`) exposing `walshadow_decode_disk(oid, bytea) -> text` for - Tier 3 types, best-effort resolution at the decode pool +- [oracle.md](oracle.md) — PgPending resolver: walshadow PG module + (`pgext/`) preloaded into shadow, serving on-disk decode over a unix + socket for Tier 3 types, best-effort resolution at the decode pool - [clickhouse-c-rs Safety model](../clickhouse-c-rs/README.md#safety-model) — clickhouse-c-rs unsafe surface (audited 2026-05-17 at `b5af579`): `Client` ownership of `PosixIo`/`Codec`, `&[u8]` over diff --git a/plans/shadow.md b/plans/shadow.md index 1d825a5..886912b 100644 --- a/plans/shadow.md +++ b/plans/shadow.md @@ -133,20 +133,13 @@ pub async fn fetch_all_descriptors(&mut self) pub async fn descriptor_by_name(&mut self, rel: &RelName) -> Result>>; // opt-in dispatch pub async fn wait_for_replay(&mut self, target: u64) -> Result; +pub async fn fetch_overlay_descriptors(&mut self, + oids: &[Oid], top_xid: u32, boundary: u64) + -> Result>; // uncommitted DDL, see below ``` ![shadow](../architecture/shadow.svg) -The batched fetch is one round trip: pg_class ⋈ pg_namespace, lateral -pg_index (pk + replident), lateral pg_attribute aggregation with -physical columns read directly (`attbyval/attlen/attalign/attstorage` — -`DROP COLUMN` zeroes `atttypid` but preserves those, so pg_type joins -LEFT and supplies typname only; dropped slots stay in `attributes`, -keeping attnum-1 indexing exact), plus `pg_last_wal_replay_lsn()` off -the same connection. Filenode resolution goes through -`pg_relation_filenode(oid)` so mapped catalogs and regular tables -resolve uniformly. - No cache, no invalidation, no event channel: descriptor history lives in the durable log ([desc_log.md](desc_log.md)); capture calls these fetchers only at catalog boundaries with shadow already applied through @@ -156,6 +149,69 @@ state. Foreign-db rejection likewise moved to the log's lookup surface absent from a boundary's fetch with a Present predecessor tombstones + emits `Dropped` — no polling sweep +## One descriptor definition + +Every fetch above assembles its `RelDescriptor` in Rust from the catalog +projections bridge worker's `SCAN` op names ([oracle.md](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. + +### Uncommitted DDL + +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. + ## Reconnect resilience `ShadowCatalog` stashes `conninfo` at construct time; diagram's @@ -315,6 +371,8 @@ backfill, net-new knobs) is against `RelDescriptor` - [emitter.md](emitter.md) — `SchemaEvent` channel consumer (`ch_ddl::DdlApplicator`), barrier-fence ordering +- [oracle.md](oracle.md) — bridge worker behind the overlay read: socket, + framing, and the `SnapshotAny` projections `SCAN` returns - [source.md](source.md) — walsender walshadow consumes from source; symmetry with walsender walshadow exposes to shadow - [future/risks.md](future/risks.md) — coarse-fire generation diff --git a/src/backfill/backup_backfill.rs b/src/backfill/backup_backfill.rs index 2abb908..8047006 100644 --- a/src/backfill/backup_backfill.rs +++ b/src/backfill/backup_backfill.rs @@ -850,6 +850,7 @@ async fn replay_gap( decoder: BufferingDecoderSink::new(ctx.log.clone(), buffer.clone()), buffer, log: ctx.log.clone(), + pending: Default::default(), subxact_tracker: SubxactTracker::new(), resolver, filter_rfns, @@ -889,6 +890,9 @@ struct ReplaySink { decoder: BufferingDecoderSink, buffer: Arc>, log: Arc, + /// Always empty: gap replay reads committed history, where no + /// transaction is in flight to have speculative catalog state + pending: crate::catalog::pending::PendingCatalog, subxact_tracker: SubxactTracker, resolver: ToastResolver, filter_rfns: HashSet<(Oid, Oid)>, @@ -933,6 +937,7 @@ impl ReplaySink { resolve_stash( &self.buffer, &self.log, + &self.pending, xid, &payload.subxacts, record.next_lsn, diff --git a/src/bin/stream.rs b/src/bin/stream.rs index 38777a2..b7bc9ea 100644 --- a/src/bin/stream.rs +++ b/src/bin/stream.rs @@ -259,6 +259,17 @@ struct Args { /// doesn't fail the daemon on first boot. #[arg(long, default_value_t = 30)] shadow_connect_timeout: u64, + /// Unix socket of the pgext bridge worker. On a daemon-owned shadow + /// this also writes `shared_preload_libraries` and the + /// `walshadow.*` GUCs into shadow's conf, so the worker starts. + /// Defaults to `/walshadow-bridge.sock`. + #[arg(long)] + bridge_socket: Option, + /// Directory holding `walshadow.so` when it isn't in PG's `$libdir`, + /// ie a build tree instead of `make install`. Written as + /// `dynamic_library_path`. + #[arg(long)] + bridge_lib_dir: Option, /// Walsender bind address. `127.0.0.1:0` lets the kernel pick a free /// port, valid only for externally managed shadow (no /// `--bootstrap-shadow-data-dir`): operator reads @@ -410,6 +421,14 @@ struct Args { bootstrap_shadow_replay_timeout: u64, } +impl Args { + fn bridge_socket_path(&self) -> PathBuf { + self.bridge_socket + .clone() + .unwrap_or_else(|| self.shadow_socket_dir.join("walshadow-bridge.sock")) + } +} + #[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() -> Result<()> { // `ctl` client mode is detected before daemon-arg parsing so it needn't @@ -919,9 +938,8 @@ async fn run_session( ); } - // Connect shadow catalog before START_REPLICATION so the tracker→drain - // wire is hot from the first record. with_transient_retry tolerates a - // still-warming shadow on boot. + // Connect bridge and shadow catalog before START_REPLICATION so the + // tracker→drain wire is hot from the first record. let shadow_conninfo = socket_conninfo( args.shadow_socket_dir .to_str() @@ -931,11 +949,25 @@ async fn run_session( &args.shadow_dbname, ); let connect_budget = Duration::from_secs(args.shadow_connect_timeout); + let bridge_path = args.bridge_socket_path(); + let bridge = Arc::new( + walshadow::bridge::connect_with_budget(&bridge_path, connect_budget) + .await + .with_context(|| format!("connect bridge at {}", bridge_path.display()))?, + ); + let info = bridge.info(); + tracing::info!( + target: "walshadow::bridge", + socket = %bridge_path.display(), + pg_version = info.map(|i| i.pg_version_num).unwrap_or(0), + in_recovery = info.map(|i| i.in_recovery).unwrap_or(false), + "bridge connected", + ); let cat_cfg = ShadowCatalogConfig::default(); let backoff_initial = cat_cfg.reconnect_backoff_initial; let backoff_max = cat_cfg.reconnect_backoff_max; let catalog = with_transient_retry(connect_budget, backoff_initial, backoff_max, async || { - ShadowCatalog::connect(&shadow_conninfo, cat_cfg.clone()).await + ShadowCatalog::connect(&shadow_conninfo, cat_cfg.clone(), bridge.clone()).await }) .await .context("connect to shadow PG")?; @@ -987,25 +1019,7 @@ async fn run_session( tracing::info!(target: "walshadow::preflight", "pre-flight passed"); } - // Oracle opens its own libpq connection so its queries don't pessimise - // the catalog's query-one path. Best-effort: connect failure disables - // the oracle, daemon keeps running with the raw-bytes fallback. - let oracle = - match walshadow::oracle::connect_with_budget(&shadow_conninfo, connect_budget).await { - Ok(o) => { - let ext = o.has_extension(); - tracing::info!( - target: "walshadow::oracle", - extension = if ext { "present" } else { "absent" }, - "oracle connected", - ); - Some(Arc::new(o)) - } - Err(e) => { - tracing::warn!(target: "walshadow::oracle", error = %e, "oracle disabled"); - None - } - }; + let oracle = Some(Arc::new(walshadow::oracle::Oracle::new(bridge.clone()))); // START_REPLICATION runs after sinks are built so archive fallback can // advance identical filter and decode paths. @@ -1065,6 +1079,11 @@ async fn run_session( .await .context("shadow database oid")?; stream.filter_mut().set_inval_db(shadow_db_oid); + let pending_cfg = ch_config + .as_ref() + .map(|c| c.pending_capture) + .unwrap_or_default(); + let pending_catalog = Arc::new(walshadow::pending::PendingCatalog::default()); let smgr_markers = stream.filter_mut().smgr_markers(); // A resumed manifest implies prior progress whose records the log must // cover; an empty/missing log there means it was lost — decode would @@ -1415,6 +1434,7 @@ async fn run_session( buffer: xact_buffer.clone(), subxact_tracker: Arc::new(Mutex::new(SubxactTracker::new())), log: desc_log.clone(), + pending: pending_catalog.clone(), stats: stats.clone(), span_registry: span_registry.clone(), config_resolver: config_resolver.clone(), @@ -1447,6 +1467,7 @@ async fn run_session( buffer: xact_buffer.clone(), subxact_tracker: Arc::new(Mutex::new(SubxactTracker::new())), log: desc_log.clone(), + pending: pending_catalog.clone(), stats: Arc::new(EmitterStats::default()), span_registry: span_registry.clone(), config_resolver: None, @@ -1490,6 +1511,8 @@ async fn run_session( catalog.clone(), xact_buffer.clone(), smgr_markers, + pending_catalog.clone(), + pending_cfg, ); let capture_stats = capture.stats_handle(); let decoder_xact = BoundaryHoldSink::new(decoder_xact, boundary_gate).with_capture(capture); @@ -1774,6 +1797,8 @@ async fn run_session( .map(|o| o.stats.summary()) .unwrap_or_default(); let oracle_stats = oracle.as_ref().map(|o| o.stats.as_ref()); + let bridge_line = bridge.stats.summary(); + let bridge_stats = Some(bridge.stats.as_ref()); let decoder_stats: &walshadow::decoder_sink::DecoderStats = &record_sink.decoder_stats; let emitter_stats: Option<&walshadow::ch_emitter::EmitterStats> = record_sink.emitter_stats.as_deref(); @@ -1801,6 +1826,7 @@ async fn run_session( decoder_stats, emitter_stats, oracle_stats, + bridge_stats, start_instant.elapsed().as_secs(), ShadowMetricsView { apply_lag_bytes: lag_bytes, @@ -1838,6 +1864,7 @@ async fn run_session( decoder = %decoder_stats.summary(), xact_buffer = %xact_line, oracle = %oracle_line, + bridge = %bridge_line, "status", ); if args.max_segments != 0 && segments_shipped >= args.max_segments { @@ -2388,6 +2415,7 @@ async fn populate_metrics( decoder_stats: &walshadow::decoder_sink::DecoderStats, emitter_stats: Option<&walshadow::ch_emitter::EmitterStats>, oracle_stats: Option<&walshadow::oracle::OracleStats>, + bridge_stats: Option<&walshadow::bridge::BridgeStats>, uptime_secs: u64, shadow_view: ShadowMetricsView, boundary_hold: &BoundaryHoldStats, @@ -2596,6 +2624,20 @@ async fn populate_metrics( desc_events_changed_total: capture.events_changed.load(Ordering::Relaxed), desc_events_dropped_total: capture.events_dropped.load(Ordering::Relaxed), descriptor_ambiguous_total: capture.ambiguities_published.load(Ordering::Relaxed), + pending_captures_total: capture.pending_captures.load(Ordering::Relaxed), + pending_rels_total: capture.pending_rels.load(Ordering::Relaxed), + pending_holds_total: capture.pending_holds.load(Ordering::Relaxed), + pending_hold_seconds_total: capture.pending_hold_nanos.load(Ordering::Relaxed) as f64 / 1e9, + pending_entries_promoted_total: capture.pending_entries_promoted.load(Ordering::Relaxed), + pending_entries_dropped_abort_total: capture + .pending_entries_dropped_abort + .load(Ordering::Relaxed), + pending_ambiguities_suppressed_total: capture + .ambiguities_suppressed + .load(Ordering::Relaxed), + pending_degraded_by_reason: std::array::from_fn(|i| { + capture.pending_degraded[i].load(Ordering::Relaxed) + }), desc_log_entries: desc_log_gauges.0, desc_log_tail_bytes: desc_log_gauges.1, desc_log_batches: desc_log_gauges.2, @@ -2612,10 +2654,38 @@ async fn populate_metrics( config_replicate_opt_out_total: config_resolver.map(|r| r.opt_out_total()).unwrap_or(0), config_backfills_pending: backfiller.map(|b| b.pending_count()).unwrap_or(0), config_backfills_pending_by_mode: backfiller.map(|b| b.pending_by_mode()).unwrap_or([0; 3]), + bridge_up: bridge_gauge(bridge_stats, |b| &b.up), + bridge_requests_by_op: bridge_ops(bridge_stats.map(|b| &b.requests)), + bridge_errors_by_op: bridge_ops(bridge_stats.map(|b| &b.errors)), + bridge_request_nanos_by_op: bridge_ops(bridge_stats.map(|b| &b.request_nanos)), + bridge_reconnects_total: bridge_gauge(bridge_stats, |b| &b.reconnects), + bridge_scan_rows_total: bridge_gauge(bridge_stats, |b| &b.scan_rows), + bridge_scan_replay_moved_total: bridge_gauge(bridge_stats, |b| &b.scan_replay_moved), + bridge_scan_subtrans_mismatch_total: bridge_gauge(bridge_stats, |b| { + &b.scan_subtrans_mismatch + }), + bridge_decode_items_total: bridge_gauge(bridge_stats, |b| &b.decode_items), + bridge_decode_item_errors_total: bridge_gauge(bridge_stats, |b| &b.decode_item_errors), }; registry.set(snap).await; } +/// Zero when bridge stats are unavailable, so series stays present +fn bridge_gauge( + stats: Option<&walshadow::bridge::BridgeStats>, + pick: fn(&walshadow::bridge::BridgeStats) -> &AtomicU64, +) -> u64 { + stats.map(|b| pick(b).load(Ordering::Relaxed)).unwrap_or(0) +} + +fn bridge_ops( + counters: Option<&[AtomicU64; walshadow::bridge::OP_COUNT]>, +) -> [u64; walshadow::bridge::OP_COUNT] { + counters + .map(|a| std::array::from_fn(|i| a[i].load(Ordering::Relaxed))) + .unwrap_or_default() +} + /// Retry transient source failures, stop when source reports missing WAL. async fn reconnect_source( cfg: &PgConfig, @@ -3129,6 +3199,12 @@ fn build_owned_shadow(args: &Args, data_dir: PathBuf) -> Shadow { cfg.ctl_timeout = Duration::from_secs(args.shadow_connect_timeout); cfg.user = args.shadow_user.clone(); cfg.dbname = args.shadow_dbname.clone(); + // Only a shadow walshadow started can be given a preload line; External + // clusters are the operator's to configure + let mut bridge = walshadow::shadow::BridgeConf::in_dir(&cfg.socket_dir); + bridge.socket_path = args.bridge_socket_path(); + bridge.library_dir = args.bridge_lib_dir.clone(); + cfg.bridge = Some(bridge); Shadow::new(cfg) } @@ -3540,6 +3616,18 @@ mod tests { assert!(resolve_shadow_start(&args_from(&["--bootstrap-mode", "direct"])).is_err()); } + #[test] + fn bridge_socket_defaults_beside_shadow_socket() { + assert_eq!( + args_from(&[]).bridge_socket_path(), + PathBuf::from("/tmp/sock/walshadow-bridge.sock") + ); + assert_eq!( + args_from(&["--bridge-socket", "/tmp/custom.sock"]).bridge_socket_path(), + PathBuf::from("/tmp/custom.sock") + ); + } + #[test] fn shadow_start_bootstrap_vs_resume_keys_on_dir_state() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/catalog/mod.rs b/src/catalog/mod.rs index e617b54..f5f20e3 100644 --- a/src/catalog/mod.rs +++ b/src/catalog/mod.rs @@ -1,5 +1,6 @@ pub mod compat; pub mod desc_log; +pub mod pending; pub mod shadow; pub mod shadow_catalog; pub mod type_bridge; diff --git a/src/catalog/pending.rs b/src/catalog/pending.rs new file mode 100644 index 0000000..e41935a --- /dev/null +++ b/src/catalog/pending.rs @@ -0,0 +1,501 @@ +//! Xid-scoped speculative catalog timeline. +//! +//! A pending descriptor is visible only to records of the transaction that +//! wrote it, and becomes durable only at that transaction's commit. Nothing +//! here reaches the [`DescriptorLog`](crate::catalog::desc_log::DescriptorLog) +//! until [`crate::source::catalog_capture`] promotes it into the commit +//! batch, so abort is a map removal with nothing to compensate. +//! +//! Slots come from the bridge worker's `SCAN` at a command boundary, where +//! shadow has replayed exactly through the boundary LSN and the writing +//! transaction's catalog rows sit on-page uncommitted. Replay parked at that +//! LSN is what does the temporal filtering: the latest uncommitted row +//! version on the page *is* the state as of the boundary. +//! +//! Keys are the tree root as the pump knew it at capture, which for a subxact +//! whose `XLOG_XACT_ASSIGNMENT` has not arrived is the subxid itself. +//! [`PendingCatalog::consolidate`] folds those keys into the top at the +//! commit, where the filter's drained member list names them all. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use tokio_postgres::types::Oid; +use walrus::pg::walparser::RelFileNode; + +use crate::schema::RelDescriptor; + +/// Why one transaction's pending coverage stopped being trustworthy. Every +/// reason falls back to commit-time capture, which is sound +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DegradeReason { + /// Boundary could not enumerate the relations it touched (whole-relcache + /// flush, pg_namespace catcache) + CaptureAll, + /// Transaction passed `pending_max_boundaries_per_xact` + CapExceeded, + /// Transaction passed its cumulative hold budget + HoldBudget, + /// Shadow replay was not parked where the boundary said it was + ReplayMismatch, + /// Overlay read failed + QueryError, +} + +impl DegradeReason { + pub const ALL: [DegradeReason; 5] = [ + DegradeReason::CaptureAll, + DegradeReason::CapExceeded, + DegradeReason::HoldBudget, + DegradeReason::ReplayMismatch, + DegradeReason::QueryError, + ]; + + pub fn label(self) -> &'static str { + match self { + DegradeReason::CaptureAll => "capture_all", + DegradeReason::CapExceeded => "cap_exceeded", + DegradeReason::HoldBudget => "hold_budget", + DegradeReason::ReplayMismatch => "replay_mismatch", + DegradeReason::QueryError => "query_error", + } + } +} + +/// One relation's shape at one command boundary of one transaction +#[derive(Debug, Clone)] +pub struct PendingSlot { + /// First LSN this shape answers for: the boundary, or the generation's + /// `XLOG_SMGR_CREATE` marker when the relation was born in this xact + pub valid_from: u64, + /// (Sub)xid current at capture; a subxact abort drops its slots + pub writer_xid: u32, + pub desc: Arc, +} + +/// One transaction tree's timeline +#[derive(Debug, Default)] +struct PendingXact { + /// Per-filenode chains, ascending by `valid_from` + chains: HashMap>, + by_oid: HashMap>, + /// Boundaries admitted, against `pending_max_boundaries_per_xact` + boundaries: u32, + /// Cumulative parked nanos, against the per-xact hold budget + hold_nanos: u64, + degraded: Option, +} + +impl PendingXact { + fn push(&mut self, slot: PendingSlot) { + let oid = slot.desc.oid; + let rfn = slot.desc.rfn; + insert_sorted(self.chains.entry(rfn).or_default(), slot.clone()); + insert_sorted(self.by_oid.entry(oid).or_default(), slot); + } + + fn absorb(&mut self, other: Self) { + self.boundaries += other.boundaries; + self.hold_nanos += other.hold_nanos; + self.degraded = self.degraded.or(other.degraded); + for slot in other.chains.into_values().flatten() { + let rfn = slot.desc.rfn; + insert_sorted(self.chains.entry(rfn).or_default(), slot); + } + for (oid, slots) in other.by_oid { + let chain = self.by_oid.entry(oid).or_default(); + for slot in slots { + insert_sorted(chain, slot); + } + } + } + + fn slots(&self) -> usize { + self.chains.values().map(Vec::len).sum() + } + + /// Drop every slot written by a member of the aborting subtree + fn drop_writers(&mut self, members: &[u32]) -> usize { + let before = self.slots(); + let keep = + |slots: &mut Vec| slots.retain(|s| !members.contains(&s.writer_xid)); + self.chains.values_mut().for_each(keep); + self.chains.retain(|_, slots| !slots.is_empty()); + self.by_oid.values_mut().for_each(keep); + self.by_oid.retain(|_, slots| !slots.is_empty()); + before - self.slots() + } +} + +/// Chains stay `valid_from`-ascending. A re-capture at the same position +/// (same command boundary re-read) replaces rather than appends: two shapes +/// at one LSN have no order between them +fn insert_sorted(chain: &mut Vec, slot: PendingSlot) { + match chain.binary_search_by_key(&slot.valid_from, |s| s.valid_from) { + Ok(at) => chain[at] = slot, + Err(at) => chain.insert(at, slot), + } +} + +/// Speculative catalog state for every in-flight catalog-dirty transaction. +/// Pump writes at command boundaries, the commit drain reads, and both the +/// abort record (pump) and the finished drain (reorder) remove +#[derive(Debug, Default)] +pub struct PendingCatalog { + by_xid: RwLock>, +} + +/// What one transaction's timeline says at promotion +pub struct PromotedXact { + /// Per-oid chains, ascending by `valid_from`, oids ascending + pub by_oid: Vec<(Oid, Vec)>, + pub degraded: Option, +} + +impl PromotedXact { + /// LSN from which the timeline answers for `oid`, `None` when it does + /// not. From the first boundary that named the relation on, each slot + /// covers up to the next; before it, the transaction had not reached a + /// `CommandCounterIncrement`, so the pre-transaction shape still reads + /// its rows. + /// + /// A degraded transaction missed at least one boundary, and a missed + /// boundary is a shape change nothing recorded, so none of its interval + /// is covered + pub fn coverage_from(&self, oid: Oid) -> Option { + if self.degraded.is_some() { + return None; + } + self.slots_for(oid).first().map(|s| s.valid_from) + } + + /// One relation's shapes, ascending by `valid_from` + pub fn slots_for(&self, oid: Oid) -> &[PendingSlot] { + self.by_oid + .binary_search_by_key(&oid, |(o, _)| *o) + .map_or(&[][..], |at| &self.by_oid[at].1) + } +} + +impl PendingCatalog { + /// Admit one command boundary against the transaction's caps. Refusal + /// degrades the transaction: a boundary the daemon declines to read is a + /// shape change it cannot account for. `Err(Some(reason))` when this + /// call is what degraded it, `Err(None)` when it already was + pub fn admit( + &self, + top_xid: u32, + max_boundaries: u32, + hold_budget_nanos: u64, + ) -> Result<(), Option> { + let mut map = self.by_xid.write().expect("pending catalog poisoned"); + let xact = map.entry(top_xid).or_default(); + if xact.degraded.is_some() { + return Err(None); + } + let over = if xact.boundaries >= max_boundaries { + Some(DegradeReason::CapExceeded) + } else if xact.hold_nanos >= hold_budget_nanos { + Some(DegradeReason::HoldBudget) + } else { + None + }; + if let Some(reason) = over { + xact.degraded = Some(reason); + return Err(Some(reason)); + } + xact.boundaries += 1; + Ok(()) + } + + /// Charge a released hold against the transaction's cumulative budget + pub fn charge_hold(&self, top_xid: u32, nanos: u64) { + let mut map = self.by_xid.write().expect("pending catalog poisoned"); + map.entry(top_xid).or_default().hold_nanos += nanos; + } + + /// First reason sticks; `true` when this call is what degraded it + pub fn degrade(&self, top_xid: u32, reason: DegradeReason) -> bool { + let mut map = self.by_xid.write().expect("pending catalog poisoned"); + let xact = map.entry(top_xid).or_default(); + if xact.degraded.is_some() { + return false; + } + xact.degraded = Some(reason); + true + } + + pub fn degraded(&self, top_xid: u32) -> Option { + let map = self.by_xid.read().expect("pending catalog poisoned"); + map.get(&top_xid).and_then(|x| x.degraded) + } + + /// Install one boundary's shapes + pub fn record(&self, top_xid: u32, slots: Vec) { + if slots.is_empty() { + return; + } + let mut map = self.by_xid.write().expect("pending catalog poisoned"); + let xact = map.entry(top_xid).or_default(); + for slot in slots { + xact.push(slot); + } + } + + /// Shape `top_xid` sees for `rfn` at `lsn`, `None` before its first + /// boundary — where the committed chain is the answer + pub fn descriptor_at( + &self, + top_xid: u32, + rfn: RelFileNode, + lsn: u64, + ) -> Option> { + let map = self.by_xid.read().expect("pending catalog poisoned"); + let chain = map.get(&top_xid)?.chains.get(&rfn)?; + chain + .iter() + .rev() + .find(|s| s.valid_from <= lsn) + .map(|s| s.desc.clone()) + } + + /// Whether an earlier boundary of this transaction already recorded the + /// filenode. A generation's smgr marker is a lower bound only for the + /// first shape seen on it; later boundaries answer from themselves + pub fn has_slots(&self, top_xid: u32, rfn: RelFileNode) -> bool { + let map = self.by_xid.read().expect("pending catalog poisoned"); + map.get(&top_xid) + .is_some_and(|x| x.chains.contains_key(&rfn)) + } + + /// One filenode's whole chain, for a caller folding many records against + /// it. Empty when the transaction has no pending coverage of it + pub fn chain(&self, top_xid: u32, rfn: RelFileNode) -> Vec { + let map = self.by_xid.read().expect("pending catalog poisoned"); + map.get(&top_xid) + .and_then(|x| x.chains.get(&rfn)) + .cloned() + .unwrap_or_default() + } + + /// Fold every member key into the top. Links arrive late, so a subxact's + /// boundaries may have keyed under the subxid; the commit's drained + /// member list is the first place the whole tree is named + pub fn consolidate(&self, top_xid: u32, members: &[u32]) { + let mut map = self.by_xid.write().expect("pending catalog poisoned"); + let mut merged: Option = map.remove(&top_xid); + for m in members.iter().filter(|m| **m != top_xid) { + let Some(other) = map.remove(m) else { continue }; + match &mut merged { + Some(acc) => acc.absorb(other), + None => merged = Some(other), + } + } + if let Some(merged) = merged { + map.insert(top_xid, merged); + } + } + + /// The consolidated tree's timeline, left in place for the drain's + /// per-record fold + pub fn promoted(&self, top_xid: u32) -> Option { + let map = self.by_xid.read().expect("pending catalog poisoned"); + let xact = map.get(&top_xid)?; + let mut by_oid: Vec<(Oid, Vec)> = xact + .by_oid + .iter() + .map(|(oid, slots)| (*oid, slots.clone())) + .collect(); + by_oid.sort_unstable_by_key(|(oid, _)| *oid); + Some(PromotedXact { + by_oid, + degraded: xact.degraded, + }) + } + + /// Drop a finished tree, returning the slots dropped + pub fn forget_tree(&self, top_xid: u32) -> usize { + let mut map = self.by_xid.write().expect("pending catalog poisoned"); + map.remove(&top_xid).map_or(0, |x| x.slots()) + } + + /// Drop an aborted subtree: whole trees keyed under a member, plus slots + /// any member wrote under another key. Runs on the pump at the abort + /// record, ahead of any later boundary that would promote them + pub fn forget_members(&self, members: &[u32]) -> usize { + let mut map = self.by_xid.write().expect("pending catalog poisoned"); + let mut dropped = 0; + for m in members { + if let Some(x) = map.remove(m) { + dropped += x.slots(); + } + } + for xact in map.values_mut() { + dropped += xact.drop_writers(members); + } + // A tree whose every slot the subtree wrote still owes its caps and + // its degrade verdict to the commit + map.retain(|_, x| x.slots() > 0 || x.boundaries > 0 || x.degraded.is_some()); + dropped + } + + /// Transactions with live timelines; a gauge, not a cap + pub fn tracked_xacts(&self) -> usize { + self.by_xid.read().expect("pending catalog poisoned").len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema::{RelName, ReplIdent}; + + fn desc(oid: Oid, rel_node: u32, name: &str) -> Arc { + Arc::new(RelDescriptor { + rfn: RelFileNode { + spc_node: 1663, + db_node: 5, + rel_node, + }, + oid, + toast_oid: 0, + namespace_oid: 2200, + rel_name: RelName::new("public", name), + kind: 'r', + persistence: 'p', + replident: ReplIdent::Default { pk_attnums: None }, + attributes: Vec::new(), + }) + } + + fn slot(valid_from: u64, writer_xid: u32, desc: Arc) -> PendingSlot { + PendingSlot { + valid_from, + writer_xid, + desc, + } + } + + #[test] + fn descriptor_at_takes_latest_slot_not_past_lsn() { + let p = PendingCatalog::default(); + let rfn = desc(16400, 7000, "t").rfn; + p.record(100, vec![slot(200, 100, desc(16400, 7000, "t"))]); + p.record(100, vec![slot(400, 100, desc(16400, 7000, "renamed"))]); + assert!(p.descriptor_at(100, rfn, 199).is_none(), "before coverage"); + assert_eq!( + &*p.descriptor_at(100, rfn, 200).unwrap().rel_name.name, + "t", + "coverage starts at valid_from" + ); + assert_eq!(&*p.descriptor_at(100, rfn, 399).unwrap().rel_name.name, "t"); + assert_eq!( + &*p.descriptor_at(100, rfn, 400).unwrap().rel_name.name, + "renamed" + ); + assert!( + p.descriptor_at(101, rfn, 500).is_none(), + "another transaction sees nothing" + ); + } + + #[test] + fn re_capture_at_one_position_replaces() { + let p = PendingCatalog::default(); + let rfn = desc(16400, 7000, "t").rfn; + p.record(100, vec![slot(200, 100, desc(16400, 7000, "first"))]); + p.record(100, vec![slot(200, 100, desc(16400, 7000, "second"))]); + assert_eq!(p.chain(100, rfn).len(), 1); + assert_eq!( + &*p.descriptor_at(100, rfn, 200).unwrap().rel_name.name, + "second" + ); + } + + #[test] + fn subxact_abort_drops_only_its_writers() { + let p = PendingCatalog::default(); + let rfn = desc(16400, 7000, "t").rfn; + p.record(100, vec![slot(200, 100, desc(16400, 7000, "pre"))]); + p.record(100, vec![slot(300, 101, desc(16400, 7000, "in-savepoint"))]); + assert_eq!(p.forget_members(&[101]), 1); + let chain = p.chain(100, rfn); + assert_eq!(chain.len(), 1); + assert_eq!(&*chain[0].desc.rel_name.name, "pre", "parent slot survives"); + } + + #[test] + fn abort_drops_a_tree_keyed_under_an_unlinked_subxid() { + let p = PendingCatalog::default(); + p.record(101, vec![slot(300, 101, desc(16400, 7000, "t"))]); + assert_eq!(p.forget_members(&[100, 101]), 1); + assert_eq!(p.tracked_xacts(), 0); + } + + #[test] + fn consolidate_folds_member_keys_into_the_top() { + let p = PendingCatalog::default(); + let rfn = desc(16400, 7000, "t").rfn; + // Child captured before its assignment named the top + p.record(101, vec![slot(200, 101, desc(16400, 7000, "child"))]); + p.record(100, vec![slot(400, 100, desc(16400, 7000, "top"))]); + p.degrade(101, DegradeReason::QueryError); + p.consolidate(100, &[100, 101]); + assert_eq!(p.tracked_xacts(), 1); + let chain = p.chain(100, rfn); + assert_eq!(chain.len(), 2); + assert_eq!(&*chain[0].desc.rel_name.name, "child"); + assert_eq!( + p.degraded(100), + Some(DegradeReason::QueryError), + "a member's degrade degrades the tree" + ); + } + + #[test] + fn admit_caps_boundaries_then_degrades() { + let p = PendingCatalog::default(); + assert_eq!(p.admit(100, 2, u64::MAX), Ok(())); + assert_eq!(p.admit(100, 2, u64::MAX), Ok(())); + assert_eq!( + p.admit(100, 2, u64::MAX), + Err(Some(DegradeReason::CapExceeded)) + ); + assert_eq!(p.degraded(100), Some(DegradeReason::CapExceeded)); + assert_eq!(p.admit(100, 8, u64::MAX), Err(None), "degrade is sticky"); + } + + #[test] + fn admit_stops_at_the_hold_budget() { + let p = PendingCatalog::default(); + assert_eq!(p.admit(100, 64, 1_000), Ok(())); + p.charge_hold(100, 1_500); + assert_eq!( + p.admit(100, 64, 1_000), + Err(Some(DegradeReason::HoldBudget)) + ); + assert_eq!(p.degraded(100), Some(DegradeReason::HoldBudget)); + } + + #[test] + fn coverage_starts_at_the_first_boundary_naming_the_oid() { + let p = PendingCatalog::default(); + p.record(100, vec![slot(400, 100, desc(16400, 7000, "t"))]); + p.record(100, vec![slot(200, 100, desc(16400, 7000, "t"))]); + let promoted = p.promoted(100).expect("tree"); + assert_eq!(promoted.coverage_from(16400), Some(200)); + assert_eq!( + promoted.coverage_from(16401), + None, + "oid with no slot is uncovered" + ); + p.degrade(100, DegradeReason::CaptureAll); + assert_eq!( + p.promoted(100).expect("tree").coverage_from(16400), + None, + "a missed boundary is a shape change nothing recorded", + ); + assert_eq!(p.forget_tree(100), 2); + assert!(p.promoted(100).is_none()); + } +} diff --git a/src/catalog/shadow.rs b/src/catalog/shadow.rs index 70c1fad..d38aa97 100644 --- a/src/catalog/shadow.rs +++ b/src/catalog/shadow.rs @@ -53,6 +53,63 @@ pub enum ShadowError { pub type Result = std::result::Result; +/// pgext bridge worker settings, written into shadow's `postgresql.conf`. +/// +/// Shadow's catalog is a read-only physical copy of source's, so +/// `CREATE EXTENSION` cannot run there. `shared_preload_libraries` is the one +/// hook walshadow owns on a shadow it started, which is what makes the worker +/// deliverable where the SQL function is not +#[derive(Debug, Clone)] +pub struct BridgeConf { + /// `walshadow.socket_path`. Also what the daemon dials + pub socket_path: PathBuf, + /// Prepended to `dynamic_library_path` when `walshadow.so` lives outside + /// `$libdir`, ie a build tree rather than `make install` + pub library_dir: Option, + pub io_timeout: Duration, + /// Bounds a catalog lock the worker cannot get, which would otherwise hang + /// against recovery + pub lock_timeout: Duration, +} + +impl BridgeConf { + /// Socket beside shadow's own, so one directory covers both + pub fn in_dir(socket_dir: &Path) -> Self { + Self { + socket_path: socket_dir.join("walshadow-bridge.sock"), + library_dir: None, + io_timeout: Duration::from_secs(30), + lock_timeout: Duration::from_secs(1), + } + } + + /// `postgresql.conf` lines that start the worker. `dbname` is the database + /// it connects to for catalog reads. + pub fn conf_text(&self, dbname: &str) -> String { + let quote = |s: &str| s.replace('\'', "''"); + let quote_path = |p: &Path| quote(&p.to_string_lossy()); + let mut out = String::from("\n# walshadow bridge worker\n"); + if let Some(dir) = &self.library_dir { + out.push_str(&format!( + "dynamic_library_path = '{}:$libdir'\n", + quote_path(dir) + )); + } + out.push_str("shared_preload_libraries = 'walshadow'\n"); + out.push_str(&format!( + "walshadow.socket_path = '{}'\n\ + walshadow.database = '{}'\n\ + walshadow.io_timeout_ms = {}\n\ + walshadow.lock_timeout_ms = {}\n", + quote_path(&self.socket_path), + quote(dbname), + self.io_timeout.as_millis(), + self.lock_timeout.as_millis(), + )); + out + } +} + #[derive(Debug, Clone)] pub struct ShadowConfig { /// `-D` to all binaries. @@ -74,6 +131,10 @@ pub struct ShadowConfig { pub user: String, /// `-d` for all probe connections. pub dbname: String, + /// `None` leaves `shared_preload_libraries` unset, so a postmaster whose + /// `walshadow.so` is missing still starts. Preloading a library PG cannot + /// resolve is a startup failure, not a warning + pub bridge: Option, } impl ShadowConfig { @@ -92,6 +153,7 @@ impl ShadowConfig { wait_poll: Duration::from_millis(200), user: "postgres".to_string(), dbname: "postgres".to_string(), + bridge: None, } } @@ -109,6 +171,15 @@ impl ShadowConfig { fn socket_str(&self) -> &str { self.socket_dir.to_str().expect("non-utf8 socket_dir") } + + /// `postgresql.conf` lines that start the bridge worker, empty when no + /// bridge is configured + fn bridge_conf(&self) -> String { + self.bridge + .as_ref() + .map(|b| b.conf_text(&self.dbname)) + .unwrap_or_default() + } } /// Minimum standby GUC values @@ -188,6 +259,11 @@ impl Shadow { &self.config } + /// Socket the preloaded bridge worker listens on, `None` when unconfigured + pub fn bridge_socket(&self) -> Option<&Path> { + self.config.bridge.as_ref().map(|b| b.socket_path.as_path()) + } + // ----- lifecycle steps ------------------------------------------- pub fn initdb(&self) -> Result<()> { @@ -232,6 +308,7 @@ impl Shadow { ); let mut f = fs::OpenOptions::new().append(true).open(&conf_path)?; f.write_all(body.as_bytes())?; + f.write_all(self.config.bridge_conf().as_bytes())?; Ok(()) } @@ -273,7 +350,10 @@ impl Shadow { sock = self.config.socket_str(), port = self.config.port, max_connections = floor.max_connections, - max_worker_processes = floor.max_worker_processes, + // Bridge takes a slot. Over the floor rather than under it: PG only + // LOGs "too many background workers" and drops the registration + max_worker_processes = + floor.max_worker_processes + u32::from(self.config.bridge.is_some()), max_wal_senders = floor.max_wal_senders, max_prepared_transactions = floor.max_prepared_transactions, max_locks_per_transaction = floor.max_locks_per_transaction, @@ -283,6 +363,7 @@ impl Shadow { let escaped = conninfo.replace('\'', "''"); conf.push_str(&format!("primary_conninfo = '{escaped}'\n")); } + conf.push_str(&self.config.bridge_conf()); let d = &self.config.data_dir; fs::write(d.join("postgresql.conf"), conf)?; fs::write( @@ -445,22 +526,6 @@ impl Shadow { Ok(out.status.code() == Some(0)) } - /// Load the `walshadow` extension if installed system-wide - /// (operators run `(cd pgext && sudo make install)`). `true` iff - /// now present; absence is tolerated, daemon falls back to raw - /// on-disk bytes for Tier 3 types outside the local matrix. - pub fn try_load_oracle_extension(&self) -> Result { - // Absence raises `extension "walshadow" is not available`; - // surface as clean false rather than failing bootstrap. - match self.psql_one("CREATE EXTENSION IF NOT EXISTS walshadow") { - Ok(_) => Ok(true), - Err(ShadowError::Process { stderr, .. }) if stderr.contains("not available") => { - Ok(false) - } - Err(e) => Err(e), - } - } - /// Feed a SQL payload to `psql -f -` (eg `pg_dump --schema-only`). pub fn apply_schema_dump(&self, sql: &str) -> Result<()> { let mut child = Command::new(self.config.bin("psql")) diff --git a/src/catalog/shadow_catalog.rs b/src/catalog/shadow_catalog.rs index 12fec99..66153b1 100644 --- a/src/catalog/shadow_catalog.rs +++ b/src/catalog/shadow_catalog.rs @@ -1,4 +1,4 @@ -//! Shadow PG SQL client for descriptor capture + name-keyed resolution. +//! Shadow PG descriptor capture + name-keyed resolution. //! //! Decode never reads this: interval-scoped answers come from the durable //! [`DescriptorLog`](crate::catalog::desc_log::DescriptorLog), which capture @@ -8,10 +8,16 @@ //! ([`ShadowCatalog::descriptor_by_name`], toast resolution) serve opt-in //! dispatch, backfill standup, and preflight. //! -//! Single-database model: instance bound to one DB. Shared catalogs -//! (`db_node == 0`) resolve from any connection via `pg_relation_filenode`. +//! One descriptor definition, `descriptor_from_rows`, over projections +//! `pgext/overlay.c` names. Bridge worker's `SCAN` supplies committed and +//! uncommitted catalog rows; one mirroring statement emits the same +//! projections off one MVCC snapshot, for committed reads the worker cannot +//! answer for a single replay position. +//! +//! Single-database model: instance bound to one DB. -use std::sync::Arc; +use std::collections::{BTreeSet, HashMap}; +use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; use backon::{ExponentialBuilder, RetryableWithContext}; @@ -20,14 +26,22 @@ use tokio_postgres::types::{Oid, PgLsn, ToSql}; use tokio_postgres::{Client, NoTls, Row}; use walrus::pg::walparser::RelFileNode; +use crate::ops::bridge::{ + AttributeRow, Bridge, BridgeError, Catalog, ClassRow, IndexRow, MAX_SCAN_OIDS, NamespaceRow, + ScanRow, TypeRow, +}; #[cfg(test)] use crate::pg::socket_conninfo; -use crate::schema::{RelAttr, RelDescriptor, RelName, ReplIdent}; +use crate::schema::{FIRST_NORMAL_OBJECT_ID, RelDescriptor, RelName, ReplIdent}; #[derive(Debug, Error)] pub enum CatalogError { #[error("pg: {0}")] Pg(#[from] tokio_postgres::Error), + /// The bridge owns its own redial, so a failure that reaches here is not + /// worth another catalog-level retry + #[error("bridge: {0}")] + Bridge(#[from] BridgeError), #[error("relation not found by filenode {0:?}")] NotFoundByFilenode(RelFileNode), #[error("relation in foreign database {0:?} (not the shadow DB)")] @@ -75,6 +89,8 @@ impl Default for ShadowCatalogConfig { #[derive(Debug, Default, Clone)] pub struct ShadowCatalogStats { pub fetches: u64, + /// Committed reads answered off the mirroring statement, not the worker + pub mirror_fetches: u64, pub replay_waits: u64, pub reconnects: u64, } @@ -87,6 +103,9 @@ pub struct ShadowCatalog { /// DB oid this client is connected to; survives `reconnect` since /// `conninfo` pins the DB current_db_oid: Option, + /// `pg_database.dattablespace`, memoized for the same reason + default_tablespace: Option, + bridge: Arc, stats: ShadowCatalogStats, } @@ -114,7 +133,11 @@ impl ShadowCatalog { /// Connect over a libpq key=value conninfo. One-shot; wrap in /// [`with_transient_retry`] for retry-on-PG-coming-up. `conninfo` is stashed /// so the client can be rebuilt when shadow PG bounces. - pub async fn connect(conninfo: &str, config: ShadowCatalogConfig) -> Result { + pub async fn connect( + conninfo: &str, + config: ShadowCatalogConfig, + bridge: Arc, + ) -> Result { let (client, conn) = tokio_postgres::connect(conninfo, NoTls).await?; tokio::spawn(async move { let _ = conn.await; @@ -125,6 +148,8 @@ impl ShadowCatalog { config, last_replay_lsn: None, current_db_oid: None, + default_tablespace: None, + bridge, stats: ShadowCatalogStats::default(), }) } @@ -153,7 +178,12 @@ impl ShadowCatalog { let Some(oid) = self.oid_by_name(rel).await? else { return Ok(None); }; - Ok(self.fetch_by_oid(oid).await?.map(Arc::new)) + Ok(self.fetch_one(oid).await?.map(Arc::new)) + } + + async fn fetch_one(&mut self, oid: Oid) -> Result> { + let (_, descs) = self.fetch_descriptors_batch(&[oid]).await?; + Ok(descs.into_iter().next()) } /// Resolve a table's TOAST relation descriptor (`pg_class.reltoastrelid` @@ -171,7 +201,7 @@ impl ShadowCatalog { if toast_oid == 0 { return Ok(None); } - Ok(self.fetch_by_oid(toast_oid).await?.map(Arc::new)) + Ok(self.fetch_one(toast_oid).await?.map(Arc::new)) } pub fn stats(&self) -> &ShadowCatalogStats { @@ -263,183 +293,205 @@ impl ShadowCatalog { } } - async fn fetch_by_oid(&mut self, oid: Oid) -> Result> { - self.stats.fetches += 1; - let row = self - .query_opt_retry( - "SELECT \ - c.oid::oid, \ - c.relnamespace::oid, \ - n.nspname::text, \ - c.relname::text, \ - c.relkind::text, \ - c.relpersistence::text, \ - c.relreplident::text, \ - c.reltoastrelid::oid, \ - coalesce(nullif(c.reltablespace, 0), \ - (SELECT dattablespace FROM pg_database \ - WHERE datname = current_database()))::oid, \ - coalesce(pg_relation_filenode(c.oid), 0)::oid \ - FROM pg_class c \ - JOIN pg_namespace n ON n.oid = c.relnamespace \ - WHERE c.oid = $1", - &[&oid], - ) - .await?; - let Some(row) = row else { return Ok(None) }; - let spc_node: Oid = row.get(8); - let rel_node: Oid = row.get(9); - let db_node = self.current_database_oid().await?; - let rfn = RelFileNode { - spc_node, - db_node, - rel_node, - }; - Ok(Some(self.descriptor_from_row(&row, rfn).await?)) - } - - /// Build from a pg_class⋈pg_namespace row whose first 8 columns are - /// (oid, relnamespace, nspname, relname, relkind, relpersistence, - /// relreplident, reltoastrelid), paired with a resolved `rfn`. - async fn descriptor_from_row(&mut self, row: &Row, rfn: RelFileNode) -> Result { - let oid: Oid = row.get(0); - let namespace_oid: Oid = row.get(1); - let namespace_name: String = row.get(2); - let name: String = row.get(3); - let kind = one_char(row.get::<_, String>(4), "relkind")?; - let persistence = one_char(row.get::<_, String>(5), "relpersistence")?; - let replident_char = one_char(row.get::<_, String>(6), "relreplident")?; - let toast_oid: Oid = row.get(7); - let replident = self.fetch_replident(replident_char, oid).await?; - let attributes = self.fetch_attributes(oid).await?; - Ok(RelDescriptor { - rfn, - oid, - toast_oid, - namespace_oid, - rel_name: RelName::new(&namespace_name, &name), - kind, - persistence, - replident, - attributes, - }) - } - - /// Batched descriptor fetch: one round trip for N oids plus the shadow's - /// replay position off the same connection. Oids absent from pg_class are - /// absent from the result (dropped rels). Zero-column rels yield empty - /// attribute vecs. + /// Batched descriptor fetch: N oids plus the shadow's replay position. + /// Oids absent from pg_class are absent from the result (dropped rels). + /// Zero-column rels yield empty attribute vecs. pub async fn fetch_descriptors_batch( &mut self, oids: &[Oid], ) -> Result<(u64, Vec)> { - let oids: Vec = oids.to_vec(); - self.fetch_descriptor_rows(DESCRIPTOR_BATCH_SQL, &[&oids]) - .await + self.fetch_committed(Scope::Oids(oids)).await } /// Every eligible user relation: capture-all + descriptor-log boot seed. pub async fn fetch_all_descriptors(&mut self) -> Result<(u64, Vec)> { - self.fetch_descriptor_rows(&DESCRIPTOR_ALL_SQL, &[]).await + self.fetch_committed(Scope::Eligible).await + } + + /// Committed catalog at one replay position. + async fn fetch_committed(&mut self, scope: Scope<'_>) -> Result<(u64, Vec)> { + self.stats.fetches += 1; + let rows = self.committed_rows(scope).await?; + let replay_lsn = rows.replay_lsn; + let db_node = self.current_db_oid().await?; + let default_tablespace = self.default_tablespace_oid().await?; + Ok((replay_lsn, rows.assemble(db_node, default_tablespace)?)) + } + + /// Worker while it can answer for one replay position, the mirroring + /// statement otherwise. Replay only sits still inside the publication + /// hold; away from one it moves between requests, so no sequence of scans + /// answers for a single position and the statement's one snapshot always + /// does. + async fn committed_rows(&mut self, scope: Scope<'_>) -> Result { + let bridge = self.bridge.clone(); + match self.scan_rows(&bridge, scope, 0, None).await { + Err(e) if worker_cannot_answer(&e) => self.mirror_rows(scope).await, + other => other, + } } - async fn fetch_descriptor_rows( + /// Descriptors as transaction `top_xid` sees them, read off shadow's pages + /// at `boundary` — the LSN the caller parked replay at. Rows the + /// transaction wrote and has not committed are included; rows it deleted + /// are not. + /// + /// Oids absent from `pg_class` are absent from the result, as in + /// [`Self::fetch_descriptors_batch`]. + pub async fn fetch_overlay_descriptors( &mut self, - sql: &str, - params: &[&(dyn ToSql + Sync)], - ) -> Result<(u64, Vec)> { + oids: &[Oid], + top_xid: u32, + boundary: u64, + ) -> Result> { + let bridge = self.bridge.clone(); + self.stats.fetches += 1; + let rows = self + .scan_rows(&bridge, Scope::Oids(oids), top_xid, Some(boundary)) + .await?; let db_node = self.current_db_oid().await?; - let rows = self.query_retry(sql, params).await?; - let mut replay_lsn = 0u64; - let mut out = Vec::with_capacity(rows.len()); - for row in &rows { - replay_lsn = row.get::<_, Option>(25).map(u64::from).unwrap_or(0); - out.push(descriptor_from_batch_row(row, db_node)?); + let default_tablespace = self.default_tablespace_oid().await?; + rows.assemble(db_node, default_tablespace) + } + + /// Projection rows off the worker. `boundary` is the LSN the caller parked + /// replay at; `None` takes the first scan's position and pins the rest to + /// it, so a replay move mid-read fails instead of tearing the descriptor. + async fn scan_rows( + &mut self, + bridge: &Bridge, + scope: Scope<'_>, + top_xid: u32, + boundary: Option, + ) -> Result { + // The worker runs one scan per oid, where `= ANY($1)` on the SQL path + // folds repeats + let scoped = match scope { + Scope::Oids(oids) => { + let mut scoped = oids.to_vec(); + scoped.sort_unstable(); + scoped.dedup(); + scoped + } + Scope::Eligible => Vec::new(), + }; + // An empty oid list is the whole catalog on the wire, never "no + // relations", so the caller's empty list has to stop here + if matches!(scope, Scope::Oids(_)) && scoped.is_empty() { + return Ok(DescriptorRows { + replay_lsn: match boundary { + Some(b) => b, + None => bridge.replay_lsn().await?, + }, + ..Default::default() + }); } - if out.is_empty() { - let row = self - .query_one_retry("SELECT pg_last_wal_replay_lsn()", &[]) - .await?; - replay_lsn = row.get::<_, Option>(0).map(u64::from).unwrap_or(0); + + let mut pin = boundary; + let mut class: Vec = scan_pinned(bridge, top_xid, &scoped, &mut pin).await?; + if matches!(scope, Scope::Eligible) { + class.retain(eligible); } - Ok((replay_lsn, out)) - } - - async fn fetch_replident(&mut self, c: char, rel_oid: Oid) -> Result { - match c { - 'd' => { - // indkey is int2vector; cast to int2[] for tokio-postgres' - // Kind::Array(int2) decode. Missing row → no PK → old = None. - let row = self - .query_opt_retry( - "SELECT indkey::int2[] \ - FROM pg_index \ - WHERE indrelid = $1 AND indisprimary = true \ - LIMIT 1", - &[&rel_oid], - ) - .await?; - let pk_attnums = row.map(|r| r.get::<_, Vec>(0)); - Ok(ReplIdent::Default { pk_attnums }) - } - 'n' => Ok(ReplIdent::Nothing), - 'f' => { - // FULL logs all columns but names no key index; still capture - // the PK so the CH ORDER BY uses it instead of `_lsn`. - let row = self - .query_opt_retry( - "SELECT indkey::int2[] \ - FROM pg_index \ - WHERE indrelid = $1 AND indisprimary = true \ - LIMIT 1", - &[&rel_oid], - ) - .await?; - let pk_attnums = row.map(|r| r.get::<_, Vec>(0)); - Ok(ReplIdent::Full { pk_attnums }) - } - 'i' => { - // indkey is int2vector; cast to int2[] for tokio-postgres' - // Kind::Array(int2) → Vec decode - let row = self - .query_opt_retry( - "SELECT indexrelid::oid, indkey::int2[] \ - FROM pg_index \ - WHERE indrelid = $1 AND indisreplident = true \ - LIMIT 1", - &[&rel_oid], - ) - .await? - .ok_or_else(|| { - CatalogError::Parse(format!( - "relreplident='i' but no pg_index row with indisreplident=true for relation {rel_oid}", - )) - })?; - let index_oid: Oid = row.get(0); - let key_attnums: Vec = row.get(1); - Ok(ReplIdent::UsingIndex { - index_oid, - key_attnums, - }) - } - other => Err(CatalogError::Parse(format!( - "unknown relreplident {other:?} (expected one of d/n/f/i)", - ))), + let pinned = pin.expect("the pg_class scan pins the position"); + if class.is_empty() { + return Ok(DescriptorRows { + replay_lsn: pinned, + ..Default::default() + }); } + // Whole-catalog pg_attribute is a seqscan of the biggest catalog there + // is, and pg_class already named every relation worth scoping to + let oids: Vec = match scope { + Scope::Oids(_) => scoped, + Scope::Eligible => class.iter().map(|c| c.oid).collect(), + }; + let attrs: Vec = scan_pinned(bridge, top_xid, &oids, &mut pin).await?; + let indexes: Vec = scan_pinned(bridge, top_xid, &oids, &mut pin).await?; + + let namespaces = self + .resolve_names( + bridge, + "SELECT oid::oid, nspname::text FROM pg_namespace \ + WHERE oid = ANY($1::oid[])", + &class.iter().map(|c| c.relnamespace).collect(), + |r: NamespaceRow| (r.oid, r.nspname), + top_xid, + pinned, + ) + .await?; + // DROP COLUMN zeroes atttypid, and no pg_type row for it is what leaves + // the slot's type_name empty + let types = self + .resolve_names( + bridge, + "SELECT oid::oid, typname::text FROM pg_type \ + WHERE oid = ANY($1::oid[])", + &attrs + .iter() + .map(|a| a.atttypid) + .filter(|oid| *oid != 0) + .collect(), + |r: TypeRow| (r.oid, r.typname), + top_xid, + pinned, + ) + .await?; + + Ok(DescriptorRows { + class, + attrs, + indexes, + namespaces, + types, + replay_lsn: pinned, + }) } - async fn fetch_attributes(&mut self, rel_oid: Oid) -> Result> { - // `attmissingval` is `anyarray` (no subscript or unnest); `::text` casts - // to PG's array_out literal `{val}`, which parse_array_one_element - // strips back to the typoutput text form for getmissingattr - let rows = self.query_retry(crate::pg::ATTR_SQL, &[&rel_oid]).await?; - rows.iter() - .map(|row| { - crate::pg::RawAttr::from_row(row) - .build() - .map_err(CatalogError::Parse) - }) - .collect() + /// The same projections off one MVCC snapshot. Committed rows only, which + /// is all the statement can see and all this path is asked for. + async fn mirror_rows(&mut self, scope: Scope<'_>) -> Result { + self.stats.mirror_fetches += 1; + let rows = match scope { + Scope::Oids(oids) => self.query_retry(&MIRROR_BATCH_SQL, &[&oids]).await?, + Scope::Eligible => self.query_retry(&MIRROR_ALL_SQL, &[]).await?, + }; + DescriptorRows::from_mirror(&rows) + } + + /// Oid → name for one whole-catalog projection, committed read first and + /// the overlay only for what it missed. + /// + /// The overlay scan behind it has no oid list and so no lock argument, and + /// refuses to answer while any foreign writer is mid-DDL. Running it only + /// for what the committed read lacks keeps that exposure to names the + /// requesting transaction created itself; a committed read never gets + /// there at all. + async fn resolve_names( + &mut self, + bridge: &Bridge, + sql: &str, + wanted: &BTreeSet, + name_of: fn(R) -> (Oid, String), + top_xid: u32, + boundary: u64, + ) -> Result> { + if wanted.is_empty() { + return Ok(HashMap::new()); + } + let list: Vec = wanted.iter().copied().collect(); + let rows = self.query_retry(sql, &[&list]).await?; + let mut names: HashMap = rows.iter().map(|r| (r.get(0), r.get(1))).collect(); + if names.len() == wanted.len() { + return Ok(names); + } + let scan = bridge.scan_at(R::CATALOG, top_xid, &[], boundary).await?; + for row in scan.parse::()? { + let (oid, name) = name_of(row); + if wanted.contains(&oid) { + names.insert(oid, name); + } + } + Ok(names) } pub async fn current_database_oid(&mut self) -> Result { @@ -462,15 +514,22 @@ impl ShadowCatalog { self.current_db_oid = Some(oid); Ok(oid) } -} -fn one_char(s: String, what: &str) -> Result { - let mut chars = s.chars(); - match (chars.next(), chars.next()) { - (Some(c), None) => Ok(c), - _ => Err(CatalogError::Parse(format!( - "expected single-char {what}, got {s:?}" - ))), + /// What `pg_class.reltablespace = 0` means, and what WAL locators carry. + /// Memoized alongside [`Self::current_db_oid`]. + async fn default_tablespace_oid(&mut self) -> Result { + if let Some(oid) = self.default_tablespace { + return Ok(oid); + } + let row = self + .query_one_retry( + "SELECT dattablespace::oid FROM pg_database WHERE datname = current_database()", + &[], + ) + .await?; + let oid = row.get(0); + self.default_tablespace = Some(oid); + Ok(oid) } } @@ -514,110 +573,305 @@ fn is_transient(err: &CatalogError) -> bool { matches!(err, CatalogError::Pg(_)) } -/// One row per live oid. Columns: -/// 0-7 pg_class scalars as in [`ShadowCatalog::descriptor_from_row`] -/// (oid, relnamespace, nspname, relname, relkind, relpersistence, -/// relreplident, reltoastrelid); 8 physical tablespace (reltablespace with -/// the 0 = database-default sentinel resolved to `dattablespace`, matching -/// WAL locators' spcOid); -/// 9 filenode (0 = no storage); 10 pk indkey; 11-12 replident index -/// (indexrelid, indkey); 13-24 pg_attribute arrays parallel by attnum, -/// physical cols direct + LEFT JOIN pg_type per [`crate::pg::ATTR_SQL`]; -/// 25 `pg_last_wal_replay_lsn()`. -const DESCRIPTOR_BATCH_SQL: &str = "SELECT \ - c.oid::oid, \ - c.relnamespace::oid, \ - n.nspname::text, \ - c.relname::text, \ - c.relkind::text, \ - c.relpersistence::text, \ - c.relreplident::text, \ - c.reltoastrelid::oid, \ - coalesce(nullif(c.reltablespace, 0), \ - (SELECT dattablespace FROM pg_database \ - WHERE datname = current_database()))::oid, \ - coalesce(pg_relation_filenode(c.oid), 0)::oid, \ - pk.attnums, \ - ri.index_oid, \ - ri.attnums, \ - att.attnums, att.names, att.type_oids, att.typmods, att.not_nulls, \ - att.droppeds, att.type_names, att.byvals, att.lens, att.aligns, \ - att.storages, att.missings, \ - pg_last_wal_replay_lsn() \ - FROM pg_class c \ - JOIN pg_namespace n ON n.oid = c.relnamespace \ - LEFT JOIN LATERAL ( \ - SELECT indkey::int2[] AS attnums FROM pg_index \ - WHERE indrelid = c.oid AND indisprimary LIMIT 1) pk ON true \ - LEFT JOIN LATERAL ( \ - SELECT indexrelid::oid AS index_oid, indkey::int2[] AS attnums \ - FROM pg_index \ - WHERE indrelid = c.oid AND indisreplident LIMIT 1) ri ON true \ - LEFT JOIN LATERAL ( \ - SELECT \ - array_agg(a.attnum ORDER BY a.attnum) AS attnums, \ - array_agg(a.attname::text ORDER BY a.attnum) AS names, \ - array_agg(a.atttypid ORDER BY a.attnum) AS type_oids, \ - array_agg(a.atttypmod ORDER BY a.attnum) AS typmods, \ - array_agg(a.attnotnull ORDER BY a.attnum) AS not_nulls, \ - array_agg(a.attisdropped ORDER BY a.attnum) AS droppeds, \ - array_agg(t.typname::text ORDER BY a.attnum) AS type_names, \ - array_agg(a.attbyval ORDER BY a.attnum) AS byvals, \ - array_agg(a.attlen ORDER BY a.attnum) AS lens, \ - array_agg(a.attalign::text ORDER BY a.attnum) AS aligns, \ - array_agg(a.attstorage::text ORDER BY a.attnum) AS storages, \ - array_agg(CASE WHEN a.atthasmissing THEN a.attmissingval::text END \ - ORDER BY a.attnum) AS missings \ - FROM pg_attribute a \ - LEFT JOIN pg_type t ON t.oid = a.atttypid \ - WHERE a.attrelid = c.oid AND a.attnum >= 1) att ON true \ - WHERE c.oid = ANY($1::oid[])"; - -/// [`DESCRIPTOR_BATCH_SQL`] over every eligible user relation instead of an -/// oid list: capture-all fallback + descriptor-log boot seed. Kinds match -/// the decodable set (heap 'r', partitioned parent 'p', matview 'm', toast -/// 't'); indexes/sequences/views never decode. -static DESCRIPTOR_ALL_SQL: std::sync::LazyLock = std::sync::LazyLock::new(|| { - let base = DESCRIPTOR_BATCH_SQL - .strip_suffix("WHERE c.oid = ANY($1::oid[])") - .expect("batch SQL suffix"); - format!("{base}WHERE c.oid >= 16384 AND c.relkind IN ('r', 'p', 'm', 't')") +/// Committed reads the worker cannot answer, so the statement does instead. +/// A worker that answered and said no is not here: that error is the caller's. +fn worker_cannot_answer(err: &CatalogError) -> bool { + matches!( + err, + CatalogError::Bridge( + BridgeError::ReplayMismatch { .. } | BridgeError::Io(_) | BridgeError::Protocol(_) + ) + ) +} + +/// Not a catalog: the read's replay position, and the one row every mirror +/// read carries whether or not the projections matched anything. +const MIRROR_POSITION: i32 = 0; + +/// `pg_last_wal_replay_lsn()` is null off a standby, where the worker reports +/// `0` for the same reason. +const NO_REPLAY: &str = "0/0"; + +/// The projections `pgext/overlay.c` emits, in the text output forms it uses, +/// as `(catalog id, text[])` rows so both sources reach the same [`ScanRow`] +/// parsers. One statement, so one snapshot covers every projection. +/// +/// `format('%s', v)` renders through the type's own output function: a +/// `::text` cast says `true` where `boolout` says `t`, and would take +/// `int2vector` out of the space-separated form the worker sends. `attnum >= +/// 1` drops the system columns the descriptor never wants; `attmissingval` +/// carries `anyarray_out` form only when `atthasmissing`. +/// +/// Position branch is first so its `pg_last_wal_replay_lsn()` is read as close +/// to snapshot acquisition as one statement allows. +fn mirror_sql(scope_pred: &str) -> String { + format!( + "WITH scoped AS MATERIALIZED (\ + SELECT c.* FROM pg_class c WHERE {scope_pred}\ + ), att AS MATERIALIZED (\ + SELECT a.* FROM pg_attribute a JOIN scoped s ON s.oid = a.attrelid \ + WHERE a.attnum >= 1\ + ) \ + SELECT {position}, ARRAY[coalesce(pg_last_wal_replay_lsn()::text, '{no_replay}')] \ + UNION ALL SELECT {class}, ARRAY[\ + format('%s', c.oid), format('%s', c.relnamespace), format('%s', c.relname), \ + format('%s', c.relkind), format('%s', c.relpersistence), \ + format('%s', c.relreplident), format('%s', c.reltoastrelid), \ + format('%s', c.reltablespace), format('%s', c.relfilenode)] \ + FROM scoped c \ + UNION ALL SELECT {attribute}, ARRAY[\ + format('%s', a.attrelid), format('%s', a.attnum), format('%s', a.attname), \ + format('%s', a.atttypid), format('%s', a.atttypmod), format('%s', a.attnotnull), \ + format('%s', a.attisdropped), format('%s', a.attbyval), format('%s', a.attlen), \ + format('%s', a.attalign), format('%s', a.attstorage), \ + CASE WHEN a.atthasmissing THEN a.attmissingval::text END] \ + FROM att a \ + UNION ALL SELECT {index}, ARRAY[\ + format('%s', i.indexrelid), format('%s', i.indrelid), \ + format('%s', i.indisprimary), format('%s', i.indisreplident), \ + format('%s', i.indkey)] \ + FROM pg_index i JOIN scoped s ON s.oid = i.indrelid \ + UNION ALL SELECT {namespace}, ARRAY[format('%s', n.oid), format('%s', n.nspname)] \ + FROM pg_namespace n WHERE n.oid IN (SELECT relnamespace FROM scoped) \ + UNION ALL SELECT {typ}, ARRAY[format('%s', t.oid), format('%s', t.typname)] \ + FROM pg_type t WHERE t.oid IN (SELECT atttypid FROM att)", + position = MIRROR_POSITION, + no_replay = NO_REPLAY, + class = Catalog::Class as u8, + attribute = Catalog::Attribute as u8, + index = Catalog::Index as u8, + namespace = Catalog::Namespace as u8, + typ = Catalog::Type as u8, + ) +} + +static MIRROR_BATCH_SQL: LazyLock = LazyLock::new(|| mirror_sql("c.oid = ANY($1::oid[])")); + +/// [`MIRROR_BATCH_SQL`] over every eligible user relation. Predicate is +/// [`eligible`] in SQL, which the scan path applies to whole-catalog rows +/// instead: scoping there is what keeps `pg_attribute` off every system rel. +static MIRROR_ALL_SQL: LazyLock = LazyLock::new(|| { + mirror_sql(&format!( + "c.oid >= {FIRST_NORMAL_OBJECT_ID} AND c.relkind IN ('r', 'p', 'm', 't')" + )) }); -/// See [`DESCRIPTOR_BATCH_SQL`] for the column plan. -fn descriptor_from_batch_row(row: &Row, db_node: Oid) -> Result { - let oid: Oid = row.get(0); - let namespace_oid: Oid = row.get(1); - let namespace_name: String = row.get(2); - let name: String = row.get(3); - let kind = one_char(row.get::<_, String>(4), "relkind")?; - let persistence = one_char(row.get::<_, String>(5), "relpersistence")?; - let replident_char = one_char(row.get::<_, String>(6), "relreplident")?; - let toast_oid: Oid = row.get(7); - let spc_node: Oid = row.get(8); - let rel_node: Oid = row.get(9); - let pk_attnums: Option> = row.get(10); - let ri_index_oid: Option = row.get(11); - let ri_attnums: Option> = row.get(12); +/// Which relations one descriptor read covers. +#[derive(Clone, Copy)] +enum Scope<'a> { + Oids(&'a [Oid]), + /// Capture-all fallback + descriptor-log boot seed + Eligible, +} + +/// Relations decode can use: user oids, and the kinds that carry heap tuples +/// (heap, partitioned parent, matview, toast). Indexes, sequences and views +/// never decode. +fn eligible(class: &ClassRow) -> bool { + class.oid >= FIRST_NORMAL_OBJECT_ID && matches!(class.relkind, 'r' | 'p' | 'm' | 't') +} + +/// One catalog's projection rows off the worker. First scan of a read fixes +/// the replay position when the caller has none of its own; every later scan +/// asserts against it. +/// +/// Chunked at [`MAX_SCAN_OIDS`]: a capture-all over a partition-heavy schema +/// names more relations than one request may carry, and every chunk shares the +/// one position anyway. +async fn scan_pinned( + bridge: &Bridge, + top_xid: u32, + oids: &[Oid], + pin: &mut Option, +) -> Result> { + // An empty list is the whole catalog on the wire, which `chunks` would + // skip asking for rather than ask once + let chunks: Vec<&[Oid]> = if oids.is_empty() { + vec![&[]] + } else { + oids.chunks(MAX_SCAN_OIDS).collect() + }; + let mut out = Vec::new(); + for chunk in chunks { + let res = match *pin { + Some(boundary) => bridge.scan_at(R::CATALOG, top_xid, chunk, boundary).await?, + None => bridge.scan_pinning(R::CATALOG, top_xid, chunk).await?, + }; + *pin = Some(res.replay_lsn_end); + out.extend(res.parse::()?); + } + Ok(out) +} + +/// One descriptor read's rows, laid out as `SCAN` projections in +/// `pgext/overlay.c`. +#[derive(Default)] +struct DescriptorRows { + class: Vec, + attrs: Vec, + indexes: Vec, + namespaces: HashMap, + types: HashMap, + replay_lsn: u64, +} + +impl DescriptorRows { + /// Rows off [`mirror_sql`], keyed by the catalog ids `SCAN` requests carry + /// in their own header. + fn from_mirror(rows: &[Row]) -> Result { + let mut out = Self::default(); + let mut position = None; + for row in rows { + let id: i32 = row.get(0); + let cols: Vec> = row.get(1); + if id == MIRROR_POSITION { + let text = cols.first().and_then(Option::as_deref).unwrap_or(NO_REPLAY); + let lsn: PgLsn = text + .parse() + .map_err(|_| CatalogError::Parse(format!("mirror replay position {text:?}")))?; + position = Some(u64::from(lsn)); + continue; + } + let catalog = u8::try_from(id) + .ok() + .and_then(Catalog::from_id) + .ok_or_else(|| CatalogError::Parse(format!("mirror catalog id {id}")))?; + match catalog { + Catalog::Class => out.class.push(ClassRow::parse(&cols)?), + Catalog::Attribute => out.attrs.push(AttributeRow::parse(&cols)?), + Catalog::Index => out.indexes.push(IndexRow::parse(&cols)?), + Catalog::Namespace => { + let row = NamespaceRow::parse(&cols)?; + out.namespaces.insert(row.oid, row.nspname); + } + Catalog::Type => { + let row = TypeRow::parse(&cols)?; + out.types.insert(row.oid, row.typname); + } + } + } + out.replay_lsn = position + .ok_or_else(|| CatalogError::Parse("mirror read carried no position".into()))?; + Ok(out) + } + + fn assemble(self, db_node: Oid, default_tablespace: Oid) -> Result> { + let mut attrs_by_rel: HashMap> = HashMap::new(); + for attr in self.attrs { + attrs_by_rel.entry(attr.attrelid).or_default().push(attr); + } + let mut indexes_by_rel: HashMap> = HashMap::new(); + for index in self.indexes { + indexes_by_rel + .entry(index.indrelid) + .or_default() + .push(index); + } + let mut seen = BTreeSet::new(); + let mut out = Vec::with_capacity(self.class.len()); + for row in &self.class { + // Two rows for one oid is the overlay's visibility predicate + // failing to apply the transaction's own delete, ie a descriptor + // built from a superseded pg_class version + if !seen.insert(row.oid) { + return Err(CatalogError::Parse(format!( + "two pg_class rows for oid {}", + row.oid + ))); + } + out.push(descriptor_from_rows( + row, + attrs_by_rel.get(&row.oid).map_or(&[][..], Vec::as_slice), + indexes_by_rel.get(&row.oid).map_or(&[][..], Vec::as_slice), + &self.namespaces, + &self.types, + db_node, + default_tablespace, + )?); + } + Ok(out) + } +} + +/// One descriptor out of projection rows already scoped to `class.oid`. The +/// single definition of the shape, so a committed read and an overlay read of +/// an unchanged relation are equal. +fn descriptor_from_rows( + class: &ClassRow, + attrs: &[AttributeRow], + indexes: &[IndexRow], + namespaces: &HashMap, + types: &HashMap, + db_node: Oid, + default_tablespace: Oid, +) -> Result { + let namespace_name = namespaces.get(&class.relnamespace).ok_or_else(|| { + CatalogError::Parse(format!( + "no pg_namespace row for relnamespace {} of relation {}", + class.relnamespace, class.oid + )) + })?; let replident = replident_from_parts( - replident_char, - oid, - pk_attnums, - ri_index_oid.zip(ri_attnums), + class.relreplident, + class.oid, + indexes + .iter() + .find(|i| i.indisprimary) + .map(|i| i.indkey.clone()), + indexes + .iter() + .find(|i| i.indisreplident) + .map(|i| (i.indexrelid, i.indkey.clone())), )?; - let attributes = attrs_from_arrays(row)?; + + let mut ordered: Vec<&AttributeRow> = attrs.iter().collect(); + ordered.sort_unstable_by_key(|a| a.attnum); + let mut attributes = Vec::with_capacity(ordered.len()); + for (i, attr) in ordered.iter().enumerate() { + // Two rows for one attnum is an ALTER's superseded version surviving + // the overlay's visibility predicate, which would shift every later + // column + if i > 0 && ordered[i - 1].attnum == attr.attnum { + return Err(CatalogError::Parse(format!( + "two pg_attribute rows for relation {} attnum {}", + class.oid, attr.attnum + ))); + } + let raw = crate::pg::RawAttr { + attnum: attr.attnum, + name: attr.attname.clone(), + type_oid: attr.atttypid, + typmod: attr.atttypmod, + not_null: attr.attnotnull, + dropped: attr.attisdropped, + type_name: types.get(&attr.atttypid).cloned(), + type_byval: attr.attbyval, + type_len: attr.attlen, + type_align: attr.attalign.to_string(), + type_storage: attr.attstorage.to_string(), + missing: attr.attmissingval.clone(), + }; + attributes.push(raw.build().map_err(CatalogError::Parse)?); + } + Ok(RelDescriptor { rfn: RelFileNode { - spc_node, + // 0 is the database-default sentinel; WAL locators carry the + // resolved tablespace + spc_node: if class.reltablespace == 0 { + default_tablespace + } else { + class.reltablespace + }, db_node, - rel_node, + rel_node: class.relfilenode, }, - oid, - toast_oid, - namespace_oid, - rel_name: RelName::new(&namespace_name, &name), - kind, - persistence, + oid: class.oid, + toast_oid: class.reltoastrelid, + namespace_oid: class.relnamespace, + rel_name: RelName::new(namespace_name, &class.relname), + kind: class.relkind, + persistence: class.relpersistence, replident, attributes, }) @@ -650,64 +904,6 @@ fn replident_from_parts( } } -/// Zip [`DESCRIPTOR_BATCH_SQL`] columns 13-24 into attrs. -fn attrs_from_arrays(row: &Row) -> Result> { - let Some(attnums) = row.get::<_, Option>>(13) else { - return Ok(Vec::new()); - }; - let names: Vec = row.get(14); - let type_oids: Vec = row.get(15); - let typmods: Vec = row.get(16); - let not_nulls: Vec = row.get(17); - let droppeds: Vec = row.get(18); - let type_names: Vec> = row.get(19); - let byvals: Vec = row.get(20); - let lens: Vec = row.get(21); - let aligns: Vec = row.get(22); - let storages: Vec = row.get(23); - let missings: Vec> = row.get(24); - let n = attnums.len(); - let lens_match = [ - names.len(), - type_oids.len(), - typmods.len(), - not_nulls.len(), - droppeds.len(), - type_names.len(), - byvals.len(), - lens.len(), - aligns.len(), - storages.len(), - missings.len(), - ] - .iter() - .all(|&l| l == n); - if !lens_match { - return Err(CatalogError::Parse( - "descriptor batch: attribute array length mismatch".into(), - )); - } - let mut out = Vec::with_capacity(n); - for i in 0..n { - let raw = crate::pg::RawAttr { - attnum: attnums[i], - name: names[i].clone(), - type_oid: type_oids[i], - typmod: typmods[i], - not_null: not_nulls[i], - dropped: droppeds[i], - type_name: type_names[i].clone(), - type_byval: byvals[i], - type_len: lens[i], - type_align: aligns[i].clone(), - type_storage: storages[i].clone(), - missing: missings[i].clone(), - }; - out.push(raw.build().map_err(CatalogError::Parse)?); - } - Ok(out) -} - #[cfg(test)] mod tests { use std::sync::Arc; @@ -715,18 +911,6 @@ mod tests { use super::*; - #[test] - fn one_char_accepts_single() { - assert_eq!(one_char("r".into(), "relkind").unwrap(), 'r'); - assert_eq!(one_char("p".into(), "relpersistence").unwrap(), 'p'); - } - - #[test] - fn one_char_rejects_multi_or_empty() { - assert!(one_char("".into(), "x").is_err()); - assert!(one_char("rr".into(), "x").is_err()); - } - #[test] fn socket_conninfo_includes_all_fields() { let s = socket_conninfo("/tmp/sock", 55434, "postgres", "postgres"); @@ -743,6 +927,183 @@ mod tests { assert!(c.reconnect_backoff_initial < c.reconnect_backoff_max); } + #[test] + fn eligible_takes_user_oids_of_heap_bearing_kinds() { + let user = |kind| ClassRow { + relkind: kind, + ..class_row() + }; + for kind in ['r', 'p', 'm', 't'] { + assert!(eligible(&user(kind)), "{kind}"); + } + for kind in ['i', 'S', 'v', 'c'] { + assert!(!eligible(&user(kind)), "{kind}"); + } + assert!(!eligible(&ClassRow { + oid: FIRST_NORMAL_OBJECT_ID - 1, + ..class_row() + })); + } + + /// pg_default, the `reltablespace = 0` sentinel's usual resolution + const DEFAULT_TABLESPACE: Oid = 1663; + + fn class_row() -> ClassRow { + ClassRow { + oid: 16384, + relnamespace: 2200, + relname: "t".into(), + relkind: 'r', + relpersistence: 'p', + relreplident: 'd', + reltoastrelid: 16387, + reltablespace: 0, + relfilenode: 16384, + } + } + + fn attr_row(attnum: i16, name: &str, type_oid: Oid) -> AttributeRow { + AttributeRow { + attrelid: 16384, + attnum, + attname: name.into(), + atttypid: type_oid, + atttypmod: -1, + attnotnull: false, + attisdropped: type_oid == 0, + attbyval: type_oid == 23, + attlen: if type_oid == 23 { 4 } else { -1 }, + attalign: 'i', + attstorage: if type_oid == 23 { 'p' } else { 'x' }, + attmissingval: None, + } + } + + fn names(pairs: &[(Oid, &str)]) -> HashMap { + pairs.iter().map(|(o, n)| (*o, (*n).to_owned())).collect() + } + + fn overlay( + class: &ClassRow, + attrs: &[AttributeRow], + indexes: &[IndexRow], + namespaces: &HashMap, + ) -> Result { + descriptor_from_rows( + class, + attrs, + indexes, + namespaces, + &names(&[(23, "int4"), (25, "text")]), + 5, + DEFAULT_TABLESPACE, + ) + } + + #[test] + fn overlay_descriptor_resolves_sentinels_and_keys() { + let attrs = [ + attr_row(2, "a", 25), + attr_row(1, "id", 23), + AttributeRow { + attmissingval: Some("{7}".into()), + ..attr_row(3, "c", 23) + }, + ]; + let indexes = [IndexRow { + indexrelid: 16390, + indrelid: 16384, + indisprimary: true, + indisreplident: false, + indkey: vec![1], + }]; + let desc = overlay(&class_row(), &attrs, &indexes, &names(&[(2200, "public")])).unwrap(); + + assert_eq!(desc.rfn.spc_node, DEFAULT_TABLESPACE); + assert_eq!(desc.rfn.db_node, 5); + assert_eq!(desc.rfn.rel_node, 16384); + assert_eq!(desc.rel_name, RelName::new("public", "t")); + assert_eq!( + desc.replident, + ReplIdent::Default { + pk_attnums: Some(vec![1]), + } + ); + // Scan order is not the descriptor's order + let cols: Vec<&str> = desc.attributes.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(cols, ["id", "a", "c"]); + assert_eq!(desc.attributes[1].type_name, "text"); + assert_eq!(desc.attributes[2].missing_text.as_deref(), Some("7")); + } + + #[test] + fn overlay_descriptor_keeps_dropped_slot_layout() { + let attrs = [attr_row(1, "id", 23), attr_row(2, "gone", 0)]; + let desc = overlay(&class_row(), &attrs, &[], &names(&[(2200, "public")])).unwrap(); + let slot = &desc.attributes[1]; + assert!(slot.dropped); + assert_eq!(slot.type_name, "", "no pg_type row for a zeroed atttypid"); + assert_eq!(slot.type_len, -1); + assert_eq!(slot.type_storage, 'x'); + } + + #[test] + fn overlay_descriptor_picks_replident_index() { + let class = ClassRow { + relreplident: 'i', + ..class_row() + }; + let indexes = [ + IndexRow { + indexrelid: 16390, + indrelid: 16384, + indisprimary: true, + indisreplident: false, + indkey: vec![1], + }, + IndexRow { + indexrelid: 16392, + indrelid: 16384, + indisprimary: false, + indisreplident: true, + indkey: vec![2, 3], + }, + ]; + let desc = overlay( + &class, + &[attr_row(1, "id", 23)], + &indexes, + &names(&[(2200, "public")]), + ) + .unwrap(); + assert_eq!( + desc.replident, + ReplIdent::UsingIndex { + index_oid: 16392, + key_attnums: vec![2, 3], + } + ); + } + + #[test] + fn overlay_descriptor_rejects_superseded_attribute() { + let attrs = [attr_row(1, "id", 23), attr_row(1, "id", 25)]; + let err = overlay(&class_row(), &attrs, &[], &names(&[(2200, "public")])).unwrap_err(); + assert!( + matches!(&err, CatalogError::Parse(m) if m.contains("attnum 1")), + "{err}" + ); + } + + #[test] + fn overlay_descriptor_needs_its_namespace_name() { + let err = overlay(&class_row(), &[], &[], &HashMap::new()).unwrap_err(); + assert!( + matches!(&err, CatalogError::Parse(m) if m.contains("pg_namespace")), + "{err}" + ); + } + #[test] fn is_transient_classifies_known_variants() { assert!(!is_transient(&CatalogError::Parse("x".into()))); diff --git a/src/decode/codecs.rs b/src/decode/codecs.rs index f3fa531..442a56a 100644 --- a/src/decode/codecs.rs +++ b/src/decode/codecs.rs @@ -2,11 +2,11 @@ //! //! - **Local**: `numeric`, `inet` / `cidr`, `interval`. Stable layout, //! mechanical decoders, per-row hot-path latency would dominate over libpq. -//! - **Deferred to the shadow extension** (`walshadow`): `jsonb`, arrays, -//! `tsvector`, every other Tier 3 type. Surfaced as +//! - **Deferred to the shadow bridge worker**: `jsonb`, arrays, `tsvector`, +//! every other Tier 3 type. Surfaced as //! [`crate::decode::heap_decoder::ColumnValue::PgPending`] carrying raw on-disk -//! bytes; resolved at emit time via `walshadow_decode_disk(oid, bytea) -> -//! text` against shadow PG. One source of truth, no codec drift. +//! bytes; resolved at emit time by shadow PG's own `typoutput`, reached over +//! the bridge socket. One source of truth, no codec drift. //! //! Each decoder takes the varlena *body* (or raw fixed-width bytes for //! `interval`) and produces a tagged value whose `text` matches PG diff --git a/src/decode/heap_decoder.rs b/src/decode/heap_decoder.rs index c5b2af4..2bb1819 100644 --- a/src/decode/heap_decoder.rs +++ b/src/decode/heap_decoder.rs @@ -242,10 +242,9 @@ pub enum ColumnValue { /// `json` Tier 3, varlena text on disk, passed through unchanged. Json(String), /// Tier 3 deferred (not numeric/inet/interval/json). Carries raw - /// on-disk body; resolved to text post-plan via - /// `walshadow_decode_disk(oid, bytea) -> text` against shadow PG - /// (`walshadow` extension), best effort: shadow may lag row's catalog - /// state. Unresolved (extension absent / NULL result, counted + /// on-disk body; resolved to text post-plan by shadow PG's `typoutput` + /// over the bridge socket, best effort: shadow may lag row's catalog + /// state. Unresolved (bridge transport failed or `typoutput` raised, counted /// `fallback_raw`): emitter appends raw on-disk bytes. PgPending { type_oid: u32, diff --git a/src/emit/ch_emitter.rs b/src/emit/ch_emitter.rs index 34e1baf..c49df0b 100644 --- a/src/emit/ch_emitter.rs +++ b/src/emit/ch_emitter.rs @@ -115,6 +115,8 @@ pub struct EmitterConfig { /// `[stream] paused`: pump idles (stops consuming source WAL) when true. /// Live via reload. pub paused: bool, + /// Pending capture cost controls, boot-only + pub pending_capture: crate::source::catalog_capture::PendingCaptureConfig, /// Per-namespace defaults keyed on PG schema name; per-table /// entries in `tables` win for the relation they name pub namespaces: HashMap, @@ -217,6 +219,7 @@ impl Default for EmitterConfig { table_initial_loads: HashMap::new(), table_opt_ins: HashMap::new(), paused: false, + pending_capture: Default::default(), namespaces: HashMap::new(), drop_table_strategy: "retain".into(), retry: RetryConfig::default(), @@ -521,13 +524,20 @@ impl EmitterConfig { // Empty string == omitted == overlay disabled. out.runtime_config_schema = Some(schema.into()); } - if let Some(v) = root - .get("stream") - .and_then(Value::as_table) - .and_then(|t| t.get("paused")) - .and_then(Value::as_bool) - { - out.paused = v; + if let Some(st) = root.get("stream").and_then(Value::as_table) { + if let Some(v) = st.get("paused").and_then(Value::as_bool) { + out.paused = v; + } + if let Some(v) = st + .get("pending_max_boundaries_per_xact") + .and_then(Value::as_integer) + { + out.pending_capture.max_boundaries_per_xact = u32::try_from(v).unwrap_or(u32::MAX); + } + if let Some(v) = st.get("pending_max_hold_ms").and_then(Value::as_integer) { + out.pending_capture.max_hold_per_xact = + Duration::from_millis(u64::try_from(v).unwrap_or(0)); + } } if let Some(src) = root.get("source").and_then(Value::as_table) && let Some(slot) = src.get("slot").and_then(Value::as_str) @@ -1498,8 +1508,8 @@ fn encode_value( kind: "unresolved TOAST pointer (xact buffer should have reassembled)", }), // PgPending normally resolves to text earlier (decode pool via the - // oracle extension). Still set here means extension absent or - // resolve fell through; best effort ships raw on-disk bytes + // bridge worker). Still set here means resolution failed + // through; best effort ships raw on-disk bytes ColumnValue::PgPending { raw, .. } => buf.append_string_bytes(raw), ColumnValue::Unsupported { .. } => Err(EmitterError::UnsupportedValue { target_column: String::new(), @@ -1516,7 +1526,6 @@ crate::atomic_stats! { pub blocks_sent, pub xacts_committed, pub unsupported_relations, - pub unsupported_values, /// `retries_attempted` counts one per failing operation, not per /// attempt (one op needing 3 retries adds 3) pub reconnects, diff --git a/src/emit/pipeline/mod.rs b/src/emit/pipeline/mod.rs index 148cbca..dba6210 100644 --- a/src/emit/pipeline/mod.rs +++ b/src/emit/pipeline/mod.rs @@ -116,6 +116,9 @@ pub struct PipelineConfig { /// Durable descriptor log: decode pool + reorder read interval-scoped /// descriptors from it pub log: Arc, + /// Speculative per-transaction catalog state shared with capture; empty + /// unless pending capture is on + pub pending: Arc, pub stats: Arc, /// Per-txn span map shared with the pump + buffer; `Some` only when OTLP /// tracing is on. Reorder parents `commit.drain`/`dispatch` under `txn`. @@ -191,6 +194,7 @@ impl PipelineConfig { buffer, subxact_tracker, log, + pending, stats, span_registry, config_resolver, @@ -249,6 +253,7 @@ impl PipelineConfig { let reorder = reorder::ReorderSink::new( buffer, log, + pending, catalog, subxact_tracker, applicator, diff --git a/src/emit/pipeline/reorder.rs b/src/emit/pipeline/reorder.rs index eecd9fd..2a27bb7 100644 --- a/src/emit/pipeline/reorder.rs +++ b/src/emit/pipeline/reorder.rs @@ -24,6 +24,7 @@ use tokio::sync::{Mutex, mpsc, oneshot, watch}; use walrus::pg::walparser::RmId; use crate::catalog::desc_log::DescriptorLog; +use crate::catalog::pending::PendingCatalog; use crate::catalog::shadow_catalog::ShadowCatalog; use crate::decode::heap_decoder::{DescribedHeap, HeapOp}; use crate::emit::ch_ddl::DdlApplicator; @@ -56,6 +57,10 @@ pub struct ReorderSink { buffer: Arc>, /// Interval-scoped descriptor oracle: stash resolution + truncate log: Arc, + /// Speculative catalog state per in-flight xact, written by capture at + /// command boundaries. Read at stash resolution, dropped once the + /// commit's drain has consumed it + pending: Arc, /// Opt-in dispatch still resolves by name against live shadow catalog: Arc>, subxact_tracker: Arc>, @@ -138,6 +143,7 @@ impl ReorderSink { pub fn new( buffer: Arc>, log: Arc, + pending: Arc, catalog: Arc>, subxact_tracker: Arc>, applicator: Option, @@ -175,6 +181,7 @@ impl ReorderSink { Self { buffer, log, + pending, catalog, subxact_tracker, applicator, @@ -749,6 +756,7 @@ impl ReorderSink { crate::xact::xact_buffer::resolve_stash( &self.buffer, &self.log, + &self.pending, xid, &payload.subxacts, record.next_lsn, @@ -785,6 +793,9 @@ impl ReorderSink { .map_err(SinkError::from)? }; self.subxact_tracker.lock().await.forget_tree(xid); + // Timeline outlived its use: resolution above already folded it + // into the outcomes this drain reads + self.pending.forget_tree(xid); // One per drained commit, incl. empty / unmapped-only self.stats.xacts_committed.fetch_add(1, Ordering::Relaxed); // Prune the committed tree's span handles (else the map grows @@ -909,6 +920,7 @@ impl ReorderSink { } self.ack.placed(seq, 0); self.subxact_tracker.lock().await.forget_tree(xid); + self.pending.forget_tree(xid); Ok(()) } } diff --git a/src/filter/dirty_tree.rs b/src/filter/dirty_tree.rs index 5ca7138..02ead9b 100644 --- a/src/filter/dirty_tree.rs +++ b/src/filter/dirty_tree.rs @@ -62,7 +62,7 @@ pub(crate) struct DirtyTree { } impl DirtyTree { - fn root(&self, xid: u32) -> u32 { + pub(crate) fn root(&self, xid: u32) -> u32 { self.top_by_xid.get(&xid).copied().unwrap_or(xid) } @@ -100,21 +100,22 @@ impl DirtyTree { } /// Xact end for `header_xid`'s tree: drop every known member's state - /// and link, return the merge (commit boundary input; abort discards). - /// Commit records list all committed children - /// (`xactGetCommittedChildren`); aborted children already dropped their - /// own state at their abort records. Linked-member sweep clears links - /// the payload cannot name + /// and link, return the merge plus the members it covered (commit + /// boundary input; abort discards the state but still names the members, + /// whose speculative catalog slots drop with them). Commit records list + /// all committed children (`xactGetCommittedChildren`); aborted children + /// already dropped their own state at their abort records. + /// Linked-member sweep clears links the payload cannot name pub(crate) fn drain_tree( &mut self, header_xid: u32, twophase_xid: Option, subxacts: &[u32], - ) -> Option { + ) -> (Option, Vec) { // Every commit/abort record lands here; clean streams keep both maps // empty, so skip the member walk entirely if self.state_by_xid.is_empty() && self.top_by_xid.is_empty() { - return None; + return (None, Vec::new()); } let roots = [Some(header_xid), twophase_xid]; let mut members: Vec = roots @@ -130,7 +131,12 @@ impl DirtyTree { .map(|(x, _)| *x), ); let mut merged: Option = None; + let mut drained: Vec = Vec::new(); for x in members { + if drained.contains(&x) { + continue; + } + drained.push(x); let Some(state) = self.state_by_xid.remove(&x) else { self.top_by_xid.remove(&x); continue; @@ -143,7 +149,7 @@ impl DirtyTree { Some(m) => m.absorb(state), } } - merged + (merged, drained) } } @@ -207,11 +213,12 @@ mod tests { t.touch(102, 20); t.touch(100, 30); // ROLLBACK TO SAVEPOINT: abort record for 101 alone - let dropped = t.drain_tree(101, None, &[]); + let (dropped, members) = t.drain_tree(101, None, &[]); assert_eq!(dropped.expect("state").first_touch, 10); + assert_eq!(members, vec![101]); assert!(t.is_dirty(100), "sibling + top dirt survives"); assert!(!t.is_dirty(101), "aborted child link cleared"); - let merged = t.drain_tree(100, None, &[102]).expect("merge"); + let merged = t.drain_tree(100, None, &[102]).0.expect("merge"); assert_eq!(merged.first_touch, 20); assert!(!t.is_dirty(100)); assert!(!t.is_dirty(102)); @@ -225,7 +232,7 @@ mod tests { s.oids.insert(16400, 50); s.oids.insert(16500, 60); s.unenumerated = true; - let m = t.drain_tree(7, None, &[101]).expect("merge"); + let m = t.drain_tree(7, None, &[101]).0.expect("merge"); assert_eq!(m.first_touch, 50); assert!(m.unenumerated); assert_eq!(m.oids[&16400], 50, "min lsn wins"); @@ -238,7 +245,9 @@ mod tests { t.link(101, 100); t.touch(101, 10); // Payload names no children; linked sweep still clears the tree - let m = t.drain_tree(100, None, &[]).expect("swept state"); + let (m, members) = t.drain_tree(100, None, &[]); + let m = m.expect("swept state"); + assert_eq!(members, vec![100, 101], "linked sweep names the member"); assert_eq!(m.first_touch, 10); assert!(!t.is_dirty(101)); assert!(t.top_by_xid.is_empty(), "link table drained"); @@ -250,7 +259,7 @@ mod tests { t.link(301, 300); t.touch(301, 10); // COMMIT PREPARED: header xid differs from prepared tree root - let m = t.drain_tree(0, Some(300), &[]).expect("prepared tree"); + let m = t.drain_tree(0, Some(300), &[]).0.expect("prepared tree"); assert_eq!(m.first_touch, 10); assert!(!t.is_dirty(300)); assert!(!t.is_dirty(301)); diff --git a/src/filter/engine.rs b/src/filter/engine.rs index 74ffc10..9fba884 100644 --- a/src/filter/engine.rs +++ b/src/filter/engine.rs @@ -25,7 +25,7 @@ use crate::filter::classify::{Class, classify}; use crate::filter::dirty_tree::{DirtyState, DirtyTree}; use crate::filter::main_data; use crate::filter::manifest::ManifestStats; -use crate::record::{AffectedOid, BoundaryInfo, Route, rmgr_label}; +use crate::record::{AffectedOid, BoundaryInfo, BoundaryKind, Route, rmgr_label}; use crate::schema::FIRST_NORMAL_OBJECT_ID; #[derive(Debug, Default, Clone, Copy)] @@ -106,17 +106,27 @@ pub(crate) struct FilterSnapshot { pub struct Verdict { pub route: Route, /// Commit record of a catalog-mutating xact (top, subxact, or prepared - /// xid wrote a catalog-touching record). Pump holds shadow publication - /// here until replay passes the commit's `next_lsn` + /// xid wrote a catalog-touching record), or a command boundary inside + /// one. Pump holds shadow publication here + /// until replay passes the record's `next_lsn` pub catalog_boundary: bool, /// Capture input; `Some` iff `catalog_boundary` pub boundary: Option>, + /// Members of a catalog-dirty tree this record aborted + pub aborted_tree: Option>>, /// User-route record whose xact tree wrote catalog state earlier in /// the stream: decoder must not decode with live descriptors, hold raw /// until commit-time capture publishes the final layout pub defer_catalog_decode: bool, } +/// What one `Xact` rmgr record did to the dirty tree +#[derive(Debug, Default)] +struct XactEnd { + boundary: Option>, + aborted_tree: Option>>, +} + /// Pump-side `XLOG_SMGR_CREATE` main-fork markers: physical rfn → creation /// LSN, the sharpest bias-early valid_from for a rotated filenode. Keyed by /// full rfn — relfilenumbers are unique only per (tablespace, database), and @@ -295,13 +305,14 @@ impl Filter { } } let defer_catalog_decode = route == Route::ToDecoder && self.dirty.is_dirty(xid); - let boundary = self.observe_xact_end(record, source_lsn, page_magic)?; + let end = self.observe_xact_end(record, source_lsn, page_magic)?; self.stats .record(class, route, record.header.total_record_length as u64); Ok(Verdict { route, - catalog_boundary: boundary.is_some(), - boundary, + catalog_boundary: end.boundary.is_some(), + boundary: end.boundary, + aborted_tree: end.aborted_tree, defer_catalog_decode, }) } @@ -319,15 +330,17 @@ impl Filter { record: &XLogRecord, source_lsn: u64, page_magic: u16, - ) -> Result>, XactPayloadError> { + ) -> Result { if record.header.resource_manager_id != RmId::Xact as u8 { - return Ok(None); + return Ok(XactEnd::default()); } let info = record.header.info; let op = info & XLOG_XACT_OPMASK; if op == XLOG_XACT_INVALIDATIONS { - self.observe_xact_invals(record, source_lsn, page_magic)?; - return Ok(None); + return Ok(XactEnd { + boundary: self.observe_xact_invals(record, source_lsn, page_magic)?, + aborted_tree: None, + }); } if op == XLOG_XACT_ASSIGNMENT { // Batched subxid → top links (every PGPROC_MAX_CACHED_SUBXIDS @@ -338,20 +351,26 @@ impl Filter { for sub in subs { self.dirty.link(sub, top); } - return Ok(None); + return Ok(XactEnd::default()); } let is_commit = op == XLOG_XACT_COMMIT || op == XLOG_XACT_COMMIT_PREPARED; let is_abort = op == XLOG_XACT_ABORT || op == XLOG_XACT_ABORT_PREPARED; if !is_commit && !is_abort { - return Ok(None); + return Ok(XactEnd::default()); } let payload = parse_xact_payload(info, &record.main_data, page_magic)?; let header_xid = record.header.xact_id; - let merged = self - .dirty - .drain_tree(header_xid, payload.twophase_xid, &payload.subxacts); + let (merged, members) = + self.dirty + .drain_tree(header_xid, payload.twophase_xid, &payload.subxacts); if !is_commit { - return Ok(None); + // Speculative catalog state the tree wrote dies with it. Named + // on the record so the drop lands on the pump, ahead of any + // later boundary that would promote it + return Ok(XactEnd { + boundary: None, + aborted_tree: merged.is_some().then(|| Arc::new(members)), + }); } // Local relcache invals: second oid source + capture-all trigger. // db 0 = shared relation; user rels there are impossible, kept for @@ -377,7 +396,7 @@ impl Filter { } let dirty_hit = merged.is_some(); if !dirty_hit && inval_oids.is_empty() && !capture_all { - return Ok(None); + return Ok(XactEnd::default()); } // Inval-only boundary (dirty tracker missed the writes): the // commit record itself is the only LSN at hand. Later than any @@ -396,29 +415,40 @@ impl Filter { }) .collect(); oids.sort_unstable_by_key(|a| a.oid); - Ok(Some(Arc::new(BoundaryInfo { - drain_xid: payload.twophase_xid.unwrap_or(header_xid), - tree_first_touch: merged.first_touch, - oids, - capture_all: capture_all || merged.unenumerated, - }))) + Ok(XactEnd { + boundary: Some(Arc::new(BoundaryInfo { + drain_xid: payload.twophase_xid.unwrap_or(header_xid), + tree_first_touch: merged.first_touch, + oids, + capture_all: capture_all || merged.unenumerated, + kind: BoundaryKind::Commit, + members, + })), + aborted_tree: None, + }) } /// `XLOG_XACT_INVALIDATIONS`: command-boundary inval set logged - /// mid-xact at `wal_level=logical`. Re-dirties the writing xid so - /// boundary classification survives a restart whose resume floor sits - /// past the xact's catalog records. Only descriptor-relevant messages - /// dirty — an entry with nothing to capture would hold publication at - /// commit for nothing + /// mid-xact at `wal_level=logical`, i.e. at every + /// `CommandCounterIncrement`. Re-dirties the writing xid so boundary + /// classification survives a restart whose resume floor sits past the + /// xact's catalog records. Only descriptor-relevant messages dirty — an + /// entry with nothing to capture would hold publication at commit for + /// nothing. + /// + /// Same set bounds record: a relation absent from it did not change shape + /// at this command, so scan stays scoped to named relations. A capture-all + /// set still bounds nothing, capture degrades xact rather than scan whole + /// catalog per command fn observe_xact_invals( &mut self, record: &XLogRecord, source_lsn: u64, page_magic: u16, - ) -> Result<(), XactPayloadError> { + ) -> Result>, XactPayloadError> { let xid = record.header.xact_id; if xid == 0 { - return Ok(()); + return Ok(None); } let invals = parse_xact_invalidations(&record.main_data, page_magic)?; let namespace_hit = invals.namespace.hits(|db| self.is_local_db(db)); @@ -435,17 +465,38 @@ impl Filter { } } if !namespace_hit && !flush && oids.is_empty() { - return Ok(()); + return Ok(None); } + let root = self.dirty.root(xid); let dirty = self.dirty.touch(xid, source_lsn); dirty.unenumerated |= namespace_hit || flush; - for oid in oids { + for oid in &oids { // Inval record LSN sits at command end: after the command's // catalog writes, before commit — a live pg_class decode's // earlier touch wins via or_insert - dirty.oids.entry(oid).or_insert(source_lsn); + dirty.oids.entry(*oid).or_insert(source_lsn); } - Ok(()) + let tree_first_touch = dirty.first_touch; + let touches: Vec = { + let mut touches: Vec = oids + .iter() + .map(|oid| AffectedOid { + oid: *oid, + pg_class_touch: dirty.oids.get(oid).copied(), + }) + .collect(); + touches.sort_unstable_by_key(|a| a.oid); + touches.dedup_by_key(|a| a.oid); + touches + }; + Ok(Some(Arc::new(BoundaryInfo { + drain_xid: root, + tree_first_touch, + oids: touches, + capture_all: namespace_hit || flush, + kind: BoundaryKind::Command { writer_xid: xid }, + members: Vec::new(), + }))) } /// Inval scope: accept db in {0, followed}; `None` (unwired) accepts any @@ -1185,6 +1236,74 @@ mod tests { assert_eq!(v.boundary.expect("boundary").oids[0].oid, 16400); } + #[test] + fn command_boundary_is_always_on_and_scopes_to_this_command_invals() { + let mut f = Filter::new(); + // Earlier command touched 16400; this one names 16500 only + f.decide_record(&xact_invals_rec(7, &[(-2, 5, 16400)]), 150, 0xD116) + .unwrap(); + let v = f + .decide_record(&xact_invals_rec(7, &[(-2, 5, 16500)]), 250, 0xD116) + .unwrap(); + let b = v.boundary.expect("command boundary"); + assert_eq!(b.kind, BoundaryKind::Command { writer_xid: 7 }); + assert_eq!(b.drain_xid, 7); + assert_eq!(b.tree_first_touch, 150); + let oids: Vec = b.oids.iter().map(|a| a.oid).collect(); + assert_eq!(oids, vec![16500], "a relation this command left alone"); + assert!(b.members.is_empty()); + // Commit still bounds, over the whole tree's oids + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 300, 0xD116) + .unwrap(); + let b = v.boundary.expect("commit boundary"); + assert_eq!(b.kind, BoundaryKind::Commit); + assert_eq!(b.members, vec![7]); + let oids: Vec = b.oids.iter().map(|a| a.oid).collect(); + assert_eq!(oids, vec![16400, 16500]); + } + + #[test] + fn command_boundary_under_subxact_names_the_known_root() { + let mut f = Filter::new(); + let mut sub = xact_invals_rec(101, &[(-2, 5, 16400)]); + sub.toplevel_xid = 100; + let v = f.decide_record(&sub, 150, 0xD116).unwrap(); + let b = v.boundary.expect("command boundary"); + assert_eq!(b.drain_xid, 100, "slots key under the tree root"); + assert_eq!(b.kind, BoundaryKind::Command { writer_xid: 101 }); + } + + #[test] + fn capture_all_command_boundary_carries_the_flag() { + let mut f = Filter::new(); + let v = f + .decide_record(&xact_invals_rec(7, &[(-2, 5, 0)]), 150, 0xD116) + .unwrap(); + let b = v.boundary.expect("command boundary"); + assert!(b.capture_all, "whole-relcache flush names no relations"); + assert!(b.oids.is_empty()); + } + + #[test] + fn abort_names_the_dirty_tree_for_pending_drop() { + let mut f = Filter::new(); + let mut sub = xact_invals_rec(101, &[(-2, 5, 16400)]); + sub.toplevel_xid = 100; + f.decide_record(&sub, 150, 0xD116).unwrap(); + // ROLLBACK TO SAVEPOINT: the subxact alone + let v = f + .decide_record(&xact_end(XLOG_XACT_ABORT, 101, &[], None), 200, 0xD116) + .unwrap(); + let members = v.aborted_tree.expect("aborted members"); + assert_eq!(*members, vec![101]); + // Clean tree: nothing speculative to drop + let v = f + .decide_record(&xact_end(XLOG_XACT_ABORT, 900, &[], None), 300, 0xD116) + .unwrap(); + assert!(v.aborted_tree.is_none()); + } + #[test] fn malformed_midxact_inval_record_poisons() { let mut f = Filter::new(); diff --git a/src/lib.rs b/src/lib.rs index 1ad97e0..271d851 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,7 +44,7 @@ pub use backfill::{ copy_backfill, opt_in, pg_path, spool, }; #[doc(hidden)] -pub use catalog::{desc_log, shadow, shadow_catalog, type_bridge}; +pub use catalog::{desc_log, pending, shadow, shadow_catalog, type_bridge}; #[doc(hidden)] pub use decode::{codecs, decoder_sink, fpi, heap_decoder, visibility, wal_xact}; #[doc(hidden)] @@ -52,7 +52,7 @@ pub use emit::{ch_ddl, ch_emitter, pipeline}; #[doc(hidden)] pub use filter::{catalog_tracker, classify, filter_segment, main_data, pg_class_decoder, rewrite}; #[doc(hidden)] -pub use ops::{control, metrics, oracle, preflight, retention, trace}; +pub use ops::{bridge, control, metrics, oracle, preflight, retention, trace}; #[doc(hidden)] pub use source::{ boundary_hold, catalog_capture, manifest, queueing_record_sink, segment_sink, shadow_stream, diff --git a/src/ops/bridge.rs b/src/ops/bridge.rs new file mode 100644 index 0000000..02ed665 --- /dev/null +++ b/src/ops/bridge.rs @@ -0,0 +1,1135 @@ +//! Client for the pgext bridge worker. +//! +//! `walshadow.so` exposes no SQL surface. It registers a background worker +//! through `shared_preload_libraries`, which walshadow writes +//! ([`Shadow::write_base_conf`](crate::catalog::shadow::Shadow::write_base_conf)). +//! Worker serves a unix socket, so it needs no `pg_proc` row on a shadow +//! standby whose catalog is a read-only physical copy of source's. +//! +//! Wire contract lives in `pgext/walshadow.h`; this is its only client. +//! [`Oracle`](crate::ops::oracle::Oracle) leaves pending values unresolved on +//! bridge failure. Catalog callers receive [`BridgeError`] and choose their +//! own degradation policy. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; + +use backon::{ExponentialBuilder, Retryable}; +use thiserror::Error; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UnixStream; +use tokio::sync::Mutex; + +/// Frame and op layouts. Must equal `WS_PROTO_VERSION` in `pgext/walshadow.h` +pub const PROTO_VERSION: u32 = 1; +/// Catalog column plans. Must equal `WS_PROJECTION_VERSION` +pub const PROJECTION_VERSION: u32 = 1; + +/// Matches the worker's `walshadow.max_request_mb` default. Larger frames are +/// refused by the worker with a connection close, so refuse locally first +const MAX_REQUEST_BYTES: usize = 64 * 1024 * 1024; +/// Whole-catalog `pg_type` text output is the largest response in practice +const MAX_RESPONSE_BYTES: usize = 256 * 1024 * 1024; +/// Matches `WS_MAX_SCAN_OIDS`. A longer list is the caller's to chunk, since +/// only the caller knows whether the chunks share a replay position +pub const MAX_SCAN_OIDS: usize = 65536; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Op { + Hello = 0x01, + Decode = 0x02, + Scan = 0x03, + ReplayLsn = 0x04, +} + +pub const OP_LABELS: [&str; 4] = ["hello", "decode", "scan", "replay_lsn"]; +pub const OP_COUNT: usize = OP_LABELS.len(); + +impl Op { + /// Index into the per-op stat arrays, parallel to [`OP_LABELS`] + fn slot(self) -> usize { + self as usize - 1 + } +} + +/// Catalogs the overlay scan covers. Ids are wire values; never renumber +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Catalog { + Class = 1, + Attribute = 2, + Index = 3, + Namespace = 4, + Type = 5, +} + +impl Catalog { + /// Wire id back to the catalog, for a row source that carries the id + /// beside the row rather than in a request it framed itself + pub fn from_id(id: u8) -> Option { + match id { + 1 => Some(Catalog::Class), + 2 => Some(Catalog::Attribute), + 3 => Some(Catalog::Index), + 4 => Some(Catalog::Namespace), + 5 => Some(Catalog::Type), + _ => None, + } + } + + /// Columns the worker projects. Bump [`PROJECTION_VERSION`] on any change + pub fn ncols(self) -> usize { + match self { + Catalog::Class => 9, + Catalog::Attribute => 12, + Catalog::Index => 5, + Catalog::Namespace | Catalog::Type => 2, + } + } +} + +#[derive(Debug, Error)] +pub enum BridgeError { + #[error("bridge io: {0}")] + Io(#[from] io::Error), + /// Malformed frame, truncated payload, or a worker whose identity changed + #[error("bridge protocol: {0}")] + Protocol(String), + /// Worker answered with status 1 + #[error("bridge worker: {0}")] + Remote(String), + /// Refused before reaching the socket, so the connection is still good + #[error("bridge request of {len} bytes over the {cap} cap")] + RequestTooLarge { len: usize, cap: usize }, + #[error( + "bridge speaks proto {proto}/projection {projection}, client wants {want_proto}/{want_projection}" + )] + Version { + proto: u32, + projection: u32, + want_proto: u32, + want_projection: u32, + }, + /// Replay moved off the boundary the caller parked it at, so the overlay + /// rows describe a different point in WAL than the caller asked about + #[error("bridge replayed to {start:X}..{end:X}, expected boundary {expected:X}")] + ReplayMismatch { expected: u64, start: u64, end: u64 }, +} + +/// Worker identity, captured by `HELLO` and re-verified on every reconnect +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Hello { + pub proto: u32, + pub projection: u32, + pub pg_version_num: u32, + pub in_recovery: bool, +} + +/// One `DECODE` item. `Error` carries the `typoutput` failure verbatim; the +/// batch around it still completed +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DecodedItem { + Text(String), + Error(String), +} + +#[derive(Clone, Debug)] +pub struct ScanResult { + /// `GetXLogReplayRecPtr` before the scan + pub replay_lsn_start: u64, + /// ... and after. Both equal the parked boundary on a correct read + pub replay_lsn_end: u64, + /// Tuples `SnapshotAny` returned, before the visibility predicate + pub scanned: u32, + /// Writers whose parentage did not resolve to the requested top xid + pub subtrans_mismatch: u32, + pub ncols: usize, + pub rows: Vec>>, +} + +crate::atomic_stats! { + pub struct BridgeStats { + /// 1 while the last transport attempt succeeded + pub up, + /// Sockets redialled after a worker exit or transport error + pub reconnects, + pub scan_rows, + pub scan_subtrans_mismatch, + /// Scans that found replay off the position the read pinned. Committed + /// reads answer these off SQL instead; overlay reads fail + pub scan_replay_moved, + pub decode_items, + pub decode_item_errors, + /// Per-op, indexed by [`OP_LABELS`] + pub requests: [AtomicU64; OP_COUNT], + pub errors: [AtomicU64; OP_COUNT], + pub request_nanos: [AtomicU64; OP_COUNT], + } +} + +impl BridgeStats { + pub fn summary(&self) -> String { + use std::fmt::Write as _; + let ld = |a: &AtomicU64| a.load(Ordering::Relaxed); + let mut s = String::from(if ld(&self.up) == 1 { "up" } else { "down" }); + for (label, n) in OP_LABELS.iter().zip(&self.requests) { + let n = ld(n); + if n > 0 { + write!(&mut s, " {label}={n}").unwrap(); + } + } + let pairs: [(&str, u64); 5] = [ + ("err", self.errors.iter().map(ld).sum()), + ("reconn", ld(&self.reconnects)), + ("item_err", ld(&self.decode_item_errors)), + ("mismatch", ld(&self.scan_subtrans_mismatch)), + ("replay_moved", ld(&self.scan_replay_moved)), + ]; + for (label, n) in pairs { + if n > 0 { + write!(&mut s, " {label}={n}").unwrap(); + } + } + s + } +} + +#[derive(Debug)] +pub struct Bridge { + path: PathBuf, + conn: Mutex>, + /// Set by the first successful `HELLO`; later dials must match it + info: OnceLock, + pub stats: Arc, +} + +impl Bridge { + /// Connect and gate on the worker's proto and projection versions. A + /// mismatch is refused rather than negotiated: the daemon would misparse + /// the projections + pub async fn connect(path: impl AsRef) -> Result { + let bridge = Self { + path: path.as_ref().to_owned(), + conn: Mutex::new(None), + info: OnceLock::new(), + stats: Arc::new(BridgeStats::default()), + }; + let stream = bridge.dial().await?; + *bridge.conn.lock().await = Some(stream); + Ok(bridge) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// `None` before the first successful `HELLO`, which [`connect`](Self::connect) + /// guarantees + pub fn info(&self) -> Option { + self.info.get().copied() + } + + pub fn is_up(&self) -> bool { + self.stats.up.load(Ordering::Relaxed) == 1 + } + + /// `pg_last_wal_replay_lsn` by shared-memory read, one round trip + pub async fn replay_lsn(&self) -> Result { + let body = self.call(Op::ReplayLsn, &[]).await?; + Cursor::at(&body, 1).u64() + } + + /// Render on-disk bytes through each type's `typoutput`. Item bytes are + /// the varlena body with its header stripped, as + /// [`ColumnValue::PgPending`](crate::decode::heap_decoder::ColumnValue) + /// carries it; the worker wraps a fresh header + pub async fn decode(&self, items: &[(u32, &[u8])]) -> Result, BridgeError> { + let size: usize = items.iter().map(|(_, raw)| raw.len() + 8).sum(); + let mut payload = Vec::with_capacity(4 + size); + payload.extend_from_slice(&(items.len() as u32).to_be_bytes()); + for (type_oid, raw) in items { + payload.extend_from_slice(&type_oid.to_be_bytes()); + payload.extend_from_slice(&(raw.len() as u32).to_be_bytes()); + payload.extend_from_slice(raw); + } + + let body = self.call(Op::Decode, &payload).await?; + let mut c = Cursor::at(&body, 1); + let n = c.u32()? as usize; + if n != items.len() { + return Err(BridgeError::Protocol(format!( + "decode answered {n} items for {} sent", + items.len() + ))); + } + let mut out = Vec::with_capacity(n); + let mut item_errors = 0u64; + for _ in 0..n { + let kind = c.u8()?; + let text = c.lenstr()?; + out.push(match kind { + 0 => DecodedItem::Text(text), + 1 => { + item_errors += 1; + DecodedItem::Error(text) + } + k => return Err(BridgeError::Protocol(format!("decode item kind {k}"))), + }); + } + self.stats + .decode_items + .fetch_add(n as u64, Ordering::Relaxed); + self.stats + .decode_item_errors + .fetch_add(item_errors, Ordering::Relaxed); + Ok(out) + } + + /// Read `cat` as transaction `top_xid` sees it, or the committed view when + /// `top_xid` is 0. `oids` scopes `pg_class`, `pg_attribute` and `pg_index` + /// to relations that transaction holds AccessExclusiveLock on; empty reads + /// the whole catalog, which is the only mode `pg_namespace` and `pg_type` + /// have. Losing the oid list loses the lock argument with it, so an + /// uncommitted whole-catalog read fails rather than guess at a writer whose + /// parentage standby `pg_subtrans` cannot resolve + pub async fn scan( + &self, + cat: Catalog, + top_xid: u32, + oids: &[u32], + ) -> Result { + let mut payload = Vec::with_capacity(9 + oids.len() * 4); + payload.push(cat as u8); + payload.extend_from_slice(&top_xid.to_be_bytes()); + payload.extend_from_slice(&(oids.len() as u32).to_be_bytes()); + for oid in oids { + payload.extend_from_slice(&oid.to_be_bytes()); + } + + let body = self.call(Op::Scan, &payload).await?; + let mut c = Cursor::at(&body, 1); + let replay_lsn_start = c.u64()?; + let replay_lsn_end = c.u64()?; + let scanned = c.u32()?; + let subtrans_mismatch = c.u32()?; + let nrows = c.u32()? as usize; + let ncols = c.u16()? as usize; + if ncols != cat.ncols() { + return Err(BridgeError::Protocol(format!( + "{cat:?} projected {ncols} columns, client expects {}", + cat.ncols() + ))); + } + // Every value costs at least its 4-byte length prefix, so a row count + // the rest of the frame cannot hold is a desync, not an allocation + if nrows.saturating_mul(ncols).saturating_mul(4) > body.len() { + return Err(BridgeError::Protocol(format!( + "{nrows} rows do not fit a {}-byte frame", + body.len() + ))); + } + let mut rows = Vec::with_capacity(nrows); + for _ in 0..nrows { + let mut row = Vec::with_capacity(ncols); + for _ in 0..ncols { + row.push(c.opt_str()?); + } + rows.push(row); + } + + self.stats + .scan_rows + .fetch_add(nrows as u64, Ordering::Relaxed); + self.stats + .scan_subtrans_mismatch + .fetch_add(u64::from(subtrans_mismatch), Ordering::Relaxed); + Ok(ScanResult { + replay_lsn_start, + replay_lsn_end, + scanned, + subtrans_mismatch, + ncols, + rows, + }) + } + + /// [`scan`](Self::scan) plus the assertion that replay never left + /// `boundary`. Equal but wrong is unreachable: replay cannot rewind and the + /// daemon holds the successor bytes + pub async fn scan_at( + &self, + cat: Catalog, + top_xid: u32, + oids: &[u32], + boundary: u64, + ) -> Result { + let res = self.scan(cat, top_xid, oids).await?; + self.pinned(res, boundary) + } + + /// First scan of a read with no boundary of its own: whatever position it + /// reports becomes the pin for the rest, so only a move inside this one + /// scan fails here + pub async fn scan_pinning( + &self, + cat: Catalog, + top_xid: u32, + oids: &[u32], + ) -> Result { + let res = self.scan(cat, top_xid, oids).await?; + let boundary = res.replay_lsn_start; + self.pinned(res, boundary) + } + + fn pinned(&self, res: ScanResult, boundary: u64) -> Result { + if res.replay_lsn_start != boundary || res.replay_lsn_end != boundary { + self.stats.scan_replay_moved.fetch_add(1, Ordering::Relaxed); + return Err(BridgeError::ReplayMismatch { + expected: boundary, + start: res.replay_lsn_start, + end: res.replay_lsn_end, + }); + } + Ok(res) + } + + /// Fresh socket plus `HELLO`. Takes no connection lock, so + /// [`call`](Self::call) may hold one across it + async fn dial(&self) -> Result { + let mut stream = UnixStream::connect(&self.path).await?; + let started = Instant::now(); + let res = round_trip(&mut stream, Op::Hello, &[]).await; + self.record(Op::Hello, started, &res); + let body = res?; + + let mut c = Cursor::at(&body, 1); + let info = Hello { + proto: c.u32()?, + projection: c.u32()?, + pg_version_num: c.u32()?, + in_recovery: c.u8()? != 0, + }; + if info.proto != PROTO_VERSION || info.projection != PROJECTION_VERSION { + return Err(BridgeError::Version { + proto: info.proto, + projection: info.projection, + want_proto: PROTO_VERSION, + want_projection: PROJECTION_VERSION, + }); + } + // A worker that came back a different build must not be trusted to + // answer requests the daemon framed against the old one + let first = *self.info.get_or_init(|| info); + if first != info { + return Err(BridgeError::Protocol(format!( + "worker identity changed across reconnect: {first:?} then {info:?}" + ))); + } + Ok(stream) + } + + async fn call(&self, op: Op, payload: &[u8]) -> Result, BridgeError> { + let started = Instant::now(); + // Refuse before the socket sees it: the worker answers a frame this + // size by closing, and a healthy connection must not pay for that + let len = payload.len() + 1; + if len > MAX_REQUEST_BYTES { + let res = Err(BridgeError::RequestTooLarge { + len, + cap: MAX_REQUEST_BYTES, + }); + self.record(op, started, &res); + return res; + } + let mut guard = self.conn.lock().await; + let mut res = match guard.as_mut() { + Some(stream) => round_trip(stream, op, payload).await, + None => Err(BridgeError::Io(io::Error::new( + io::ErrorKind::NotConnected, + "bridge disconnected", + ))), + }; + // A worker exit drops the socket and `bgw_restart_time` brings it back. + // Every op is read-only, so replaying one costs nothing + if is_transport_error(&res) { + *guard = None; + self.stats.reconnects.fetch_add(1, Ordering::Relaxed); + match self.dial().await { + Ok(mut stream) => { + res = round_trip(&mut stream, op, payload).await; + if !is_transport_error(&res) { + *guard = Some(stream); + } + } + Err(e) => res = Err(e), + } + } + drop(guard); + self.record(op, started, &res); + res + } + + fn record(&self, op: Op, started: Instant, res: &Result, BridgeError>) { + let slot = op.slot(); + self.stats.requests[slot].fetch_add(1, Ordering::Relaxed); + self.stats.request_nanos[slot] + .fetch_add(started.elapsed().as_nanos() as u64, Ordering::Relaxed); + if res.is_err() { + self.stats.errors[slot].fetch_add(1, Ordering::Relaxed); + } + // A worker that answered with an error status is still up. A frame + // this side refused never reached it, so it says nothing either way + if !matches!(res, Err(BridgeError::RequestTooLarge { .. })) { + self.stats + .up + .store(u64::from(!is_transport_error(res)), Ordering::Relaxed); + } + } +} + +/// Errors that mean the socket is unusable, as opposed to a worker that +/// answered and said no +fn is_transport_error(res: &Result, BridgeError>) -> bool { + matches!(res, Err(BridgeError::Io(_)) | Err(BridgeError::Protocol(_))) +} + +async fn round_trip( + stream: &mut UnixStream, + op: Op, + payload: &[u8], +) -> Result, BridgeError> { + let len = payload.len() + 1; + // One write: a peer that dribbles a partial frame is what the worker's + // io_timeout_ms exists to bound, and the daemon must not be that peer + let mut frame = Vec::with_capacity(4 + len); + frame.extend_from_slice(&(len as u32).to_be_bytes()); + frame.push(op as u8); + frame.extend_from_slice(payload); + stream.write_all(&frame).await?; + stream.flush().await?; + + let mut hdr = [0u8; 4]; + stream.read_exact(&mut hdr).await?; + let len = u32::from_be_bytes(hdr) as usize; + if len == 0 || len > MAX_RESPONSE_BYTES { + return Err(BridgeError::Protocol(format!( + "response frame of {len} bytes" + ))); + } + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await?; + + match body[0] { + 0 => Ok(body), + 1 => Err(BridgeError::Remote(Cursor::at(&body, 1).lenstr()?)), + s => Err(BridgeError::Protocol(format!("response status {s}"))), + } +} + +/// Connect with a wall-clock budget while shadow reaches consistency. +/// Matches catalog's +/// [`with_transient_retry`](crate::catalog::shadow_catalog::with_transient_retry) shape +pub async fn connect_with_budget(path: &Path, budget: Duration) -> Result { + let deadline = tokio::time::Instant::now() + budget; + (|| Bridge::connect(path)) + .retry( + ExponentialBuilder::default() + .with_min_delay(Duration::from_millis(100)) + .with_max_delay(Duration::from_secs(1)) + .without_max_times(), + ) + // Version skew will not resolve by waiting + .when(move |e: &BridgeError| { + !matches!(e, BridgeError::Version { .. }) && tokio::time::Instant::now() < deadline + }) + .await +} + +// ----- projections --------------------------------------------------------- + +/// One row of a catalog projection. Column order must match `pgext/overlay.c` +pub trait ScanRow: Sized { + const CATALOG: Catalog; + fn parse(row: &[Option]) -> Result; +} + +impl ScanResult { + pub fn parse(&self) -> Result, BridgeError> { + if self.ncols != T::CATALOG.ncols() { + return Err(BridgeError::Protocol(format!( + "{:?} rows have {} columns", + T::CATALOG, + self.ncols + ))); + } + self.rows.iter().map(|r| T::parse(r)).collect() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClassRow { + pub oid: u32, + pub relnamespace: u32, + pub relname: String, + pub relkind: char, + pub relpersistence: char, + pub relreplident: char, + pub reltoastrelid: u32, + /// `0` means database default, which the daemon resolves against + /// `pg_database` as its SQL already does + pub reltablespace: u32, + /// The column, not `pg_relation_filenode()`: that goes through relcache and + /// would not see the overlay. User relations are never mapped + pub relfilenode: u32, +} + +impl ScanRow for ClassRow { + const CATALOG: Catalog = Catalog::Class; + + fn parse(row: &[Option]) -> Result { + Ok(Self { + oid: field(row, 0)?.parse().map_err(|_| bad(row, 0))?, + relnamespace: field(row, 1)?.parse().map_err(|_| bad(row, 1))?, + relname: field(row, 2)?.to_owned(), + relkind: only_char(row, 3)?, + relpersistence: only_char(row, 4)?, + relreplident: only_char(row, 5)?, + reltoastrelid: field(row, 6)?.parse().map_err(|_| bad(row, 6))?, + reltablespace: field(row, 7)?.parse().map_err(|_| bad(row, 7))?, + relfilenode: field(row, 8)?.parse().map_err(|_| bad(row, 8))?, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AttributeRow { + pub attrelid: u32, + pub attnum: i16, + pub attname: String, + pub atttypid: u32, + pub atttypmod: i32, + pub attnotnull: bool, + pub attisdropped: bool, + pub attbyval: bool, + pub attlen: i16, + pub attalign: char, + pub attstorage: char, + /// `anyarray_out` form, `None` unless `atthasmissing` + pub attmissingval: Option, +} + +impl ScanRow for AttributeRow { + const CATALOG: Catalog = Catalog::Attribute; + + fn parse(row: &[Option]) -> Result { + Ok(Self { + attrelid: field(row, 0)?.parse().map_err(|_| bad(row, 0))?, + attnum: field(row, 1)?.parse().map_err(|_| bad(row, 1))?, + attname: field(row, 2)?.to_owned(), + atttypid: field(row, 3)?.parse().map_err(|_| bad(row, 3))?, + atttypmod: field(row, 4)?.parse().map_err(|_| bad(row, 4))?, + attnotnull: pg_bool(row, 5)?, + attisdropped: pg_bool(row, 6)?, + attbyval: pg_bool(row, 7)?, + attlen: field(row, 8)?.parse().map_err(|_| bad(row, 8))?, + attalign: only_char(row, 9)?, + attstorage: only_char(row, 10)?, + attmissingval: row.get(11).and_then(|v| v.clone()), + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IndexRow { + pub indexrelid: u32, + pub indrelid: u32, + pub indisprimary: bool, + pub indisreplident: bool, + /// `int2vectorout` form parsed out: attnums in index order, `0` for an + /// expression column + pub indkey: Vec, +} + +impl ScanRow for IndexRow { + const CATALOG: Catalog = Catalog::Index; + + fn parse(row: &[Option]) -> Result { + let indkey = field(row, 4)? + .split_whitespace() + .map(|t| t.parse::().map_err(|_| bad(row, 4))) + .collect::, _>>()?; + Ok(Self { + indexrelid: field(row, 0)?.parse().map_err(|_| bad(row, 0))?, + indrelid: field(row, 1)?.parse().map_err(|_| bad(row, 1))?, + indisprimary: pg_bool(row, 2)?, + indisreplident: pg_bool(row, 3)?, + indkey, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NamespaceRow { + pub oid: u32, + pub nspname: String, +} + +impl ScanRow for NamespaceRow { + const CATALOG: Catalog = Catalog::Namespace; + + fn parse(row: &[Option]) -> Result { + Ok(Self { + oid: field(row, 0)?.parse().map_err(|_| bad(row, 0))?, + nspname: field(row, 1)?.to_owned(), + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TypeRow { + pub oid: u32, + pub typname: String, +} + +impl ScanRow for TypeRow { + const CATALOG: Catalog = Catalog::Type; + + fn parse(row: &[Option]) -> Result { + Ok(Self { + oid: field(row, 0)?.parse().map_err(|_| bad(row, 0))?, + typname: field(row, 1)?.to_owned(), + }) + } +} + +fn field(row: &[Option], i: usize) -> Result<&str, BridgeError> { + match row.get(i) { + Some(Some(v)) => Ok(v), + Some(None) => Err(BridgeError::Protocol(format!("column {i} is null"))), + None => Err(BridgeError::Protocol(format!("column {i} missing"))), + } +} + +fn bad(row: &[Option], i: usize) -> BridgeError { + let got = row.get(i).and_then(|v| v.as_deref()).unwrap_or(""); + BridgeError::Protocol(format!("column {i} unparsable: {got:?}")) +} + +/// `boolout` renders `t` / `f` +fn pg_bool(row: &[Option], i: usize) -> Result { + match field(row, i)? { + "t" => Ok(true), + "f" => Ok(false), + _ => Err(bad(row, i)), + } +} + +/// `charout` on a PG `"char"` column +fn only_char(row: &[Option], i: usize) -> Result { + let mut cs = field(row, i)?.chars(); + match (cs.next(), cs.next()) { + (Some(c), None) => Ok(c), + _ => Err(bad(row, i)), + } +} + +// ----- framing ------------------------------------------------------------- + +struct Cursor<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + fn at(buf: &'a [u8], pos: usize) -> Self { + Self { buf, pos } + } + + fn take(&mut self, n: usize) -> Result<&'a [u8], BridgeError> { + let end = self.pos.checked_add(n).ok_or_else(|| self.short(n))?; + let out = self.buf.get(self.pos..end).ok_or_else(|| self.short(n))?; + self.pos = end; + Ok(out) + } + + fn short(&self, n: usize) -> BridgeError { + BridgeError::Protocol(format!( + "want {n} bytes at offset {}, frame is {}", + self.pos, + self.buf.len() + )) + } + + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn u16(&mut self) -> Result { + let b: [u8; 2] = self.take(2)?.try_into().expect("take yields 2"); + Ok(u16::from_be_bytes(b)) + } + + fn u32(&mut self) -> Result { + let b: [u8; 4] = self.take(4)?.try_into().expect("take yields 4"); + Ok(u32::from_be_bytes(b)) + } + + fn u64(&mut self) -> Result { + let b: [u8; 8] = self.take(8)?.try_into().expect("take yields 8"); + Ok(u64::from_be_bytes(b)) + } + + fn lenstr(&mut self) -> Result { + let n = self.u32()? as usize; + text(self.take(n)?) + } + + /// Column value: `i32` length, `-1` null + fn opt_str(&mut self) -> Result, BridgeError> { + let n = self.u32()? as i32; + if n < 0 { + return Ok(None); + } + Ok(Some(text(self.take(n as usize)?)?)) + } +} + +/// Shadow is always initdb'd UTF8, and the SQL path constrains the same way +fn text(b: &[u8]) -> Result { + String::from_utf8(b.to_vec()).map_err(|_| BridgeError::Protocol("non-UTF8 payload".to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::net::UnixListener; + + #[test] + fn cursor_rejects_truncation() { + let buf = [0u8, 0, 0, 4, 1, 2]; + let mut c = Cursor::at(&buf, 0); + assert_eq!(c.u32().unwrap(), 4); + assert!(matches!(c.take(4), Err(BridgeError::Protocol(_)))); + } + + #[test] + fn cursor_reads_null_column() { + let mut buf = (-1i32).to_be_bytes().to_vec(); + buf.extend_from_slice(&2u32.to_be_bytes()); + buf.extend_from_slice(b"hi"); + let mut c = Cursor::at(&buf, 0); + assert_eq!(c.opt_str().unwrap(), None); + assert_eq!(c.opt_str().unwrap().as_deref(), Some("hi")); + } + + #[test] + fn class_row_parses_projection_order() { + let row: Vec> = ["16384", "2200", "t", "r", "p", "d", "16387", "0", "16384"] + .iter() + .map(|s| Some((*s).to_string())) + .collect(); + let parsed = ClassRow::parse(&row).unwrap(); + assert_eq!(parsed.oid, 16384); + assert_eq!(parsed.relname, "t"); + assert_eq!(parsed.relkind, 'r'); + assert_eq!(parsed.relreplident, 'd'); + assert_eq!(parsed.reltablespace, 0); + } + + #[test] + fn attribute_row_keeps_missingval_null() { + let mut row: Vec> = + ["16384", "1", "id", "23", "-1", "t", "f", "t", "4", "i", "p"] + .iter() + .map(|s| Some((*s).to_string())) + .collect(); + row.push(None); + let parsed = AttributeRow::parse(&row).unwrap(); + assert_eq!(parsed.attnum, 1); + assert!(parsed.attnotnull && parsed.attbyval && !parsed.attisdropped); + assert_eq!(parsed.attalign, 'i'); + assert_eq!(parsed.attmissingval, None); + } + + #[test] + fn index_row_parses_int2vector_form() { + let row: Vec> = ["16390", "16384", "t", "f", "1 3"] + .iter() + .map(|s| Some((*s).to_string())) + .collect(); + let parsed = IndexRow::parse(&row).unwrap(); + assert_eq!(parsed.indkey, vec![1, 3]); + assert!(parsed.indisprimary && !parsed.indisreplident); + } + + #[test] + fn catalog_ids_round_trip() { + for cat in [ + Catalog::Class, + Catalog::Attribute, + Catalog::Index, + Catalog::Namespace, + Catalog::Type, + ] { + assert_eq!(Catalog::from_id(cat as u8), Some(cat)); + } + assert_eq!(Catalog::from_id(0), None); + assert_eq!(Catalog::from_id(6), None); + } + + #[test] + fn scan_result_refuses_wrong_projection_width() { + let res = ScanResult { + replay_lsn_start: 0, + replay_lsn_end: 0, + scanned: 0, + subtrans_mismatch: 0, + ncols: 2, + rows: vec![], + }; + assert!(res.parse::().is_err()); + } + + #[test] + fn stats_summary_skips_zero_buckets() { + let s = BridgeStats::default(); + s.up.store(1, Ordering::Relaxed); + s.requests[Op::Scan.slot()].store(3, Ordering::Relaxed); + s.reconnects.store(1, Ordering::Relaxed); + let out = s.summary(); + assert!(out.starts_with("up")); + assert!(out.contains("scan=3")); + assert!(out.contains("reconn=1")); + assert!(!out.contains("decode=")); + } + + /// Frames a canned response body, matching the worker's `u32 len | u8 + /// status | payload` + fn frame(body: Vec) -> Vec { + let mut out = (body.len() as u32).to_be_bytes().to_vec(); + out.extend_from_slice(&body); + out + } + + fn hello_body(proto: u32, projection: u32) -> Vec { + let mut b = vec![0u8]; + b.extend_from_slice(&proto.to_be_bytes()); + b.extend_from_slice(&projection.to_be_bytes()); + b.extend_from_slice(&170004u32.to_be_bytes()); + b.push(1); + b + } + + /// Reads one request frame, writes the next canned response. `None` closes + /// the connection instead, standing in for a worker exit + async fn fake_worker(listener: UnixListener, script: Vec>>) { + let mut script = script.into_iter(); + loop { + let Ok((mut sock, _)) = listener.accept().await else { + return; + }; + loop { + let mut hdr = [0u8; 4]; + if sock.read_exact(&mut hdr).await.is_err() { + break; + } + let mut body = vec![0u8; u32::from_be_bytes(hdr) as usize]; + if sock.read_exact(&mut body).await.is_err() { + break; + } + match script.next() { + Some(Some(resp)) => { + if sock.write_all(&frame(resp)).await.is_err() { + break; + } + } + Some(None) | None => break, + } + } + } + } + + fn spawn_worker(script: Vec>>) -> (tempfile::TempDir, PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("bridge.sock"); + let listener = UnixListener::bind(&path).unwrap(); + tokio::spawn(fake_worker(listener, script)); + (tmp, path) + } + + #[tokio::test(flavor = "current_thread")] + async fn connect_refuses_projection_skew() { + let (_tmp, path) = spawn_worker(vec![Some(hello_body(PROTO_VERSION, 99))]); + let err = Bridge::connect(&path).await.unwrap_err(); + assert!( + matches!(err, BridgeError::Version { projection: 99, .. }), + "got {err:?}" + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn decode_round_trip_maps_item_kinds() { + let mut body = vec![0u8]; + body.extend_from_slice(&2u32.to_be_bytes()); + body.push(0); + body.extend_from_slice(&2u32.to_be_bytes()); + body.extend_from_slice(b"42"); + body.push(1); + body.extend_from_slice(&4u32.to_be_bytes()); + body.extend_from_slice(b"boom"); + let (_tmp, path) = spawn_worker(vec![ + Some(hello_body(PROTO_VERSION, PROJECTION_VERSION)), + Some(body), + ]); + + let bridge = Bridge::connect(&path).await.unwrap(); + let out = bridge + .decode(&[(23, &[42, 0, 0, 0]), (114, &[1])]) + .await + .unwrap(); + assert_eq!( + out, + vec![ + DecodedItem::Text("42".to_string()), + DecodedItem::Error("boom".to_string()), + ] + ); + assert_eq!(bridge.stats.decode_items.load(Ordering::Relaxed), 2); + assert_eq!(bridge.stats.decode_item_errors.load(Ordering::Relaxed), 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn scan_at_rejects_moved_replay() { + let mut body = vec![0u8]; + body.extend_from_slice(&0x1000u64.to_be_bytes()); + body.extend_from_slice(&0x2000u64.to_be_bytes()); + body.extend_from_slice(&0u32.to_be_bytes()); + body.extend_from_slice(&0u32.to_be_bytes()); + body.extend_from_slice(&0u32.to_be_bytes()); + body.extend_from_slice(&2u16.to_be_bytes()); + let (_tmp, path) = spawn_worker(vec![ + Some(hello_body(PROTO_VERSION, PROJECTION_VERSION)), + Some(body), + ]); + + let bridge = Bridge::connect(&path).await.unwrap(); + let err = bridge + .scan_at(Catalog::Namespace, 700, &[], 0x1000) + .await + .unwrap_err(); + assert!( + matches!(err, BridgeError::ReplayMismatch { end: 0x2000, .. }), + "got {err:?}" + ); + } + + /// Header shape only; `scan_pinning` never reaches the row bytes here + fn scan_body(start: u64, end: u64) -> Vec { + let mut b = vec![0u8]; + b.extend_from_slice(&start.to_be_bytes()); + b.extend_from_slice(&end.to_be_bytes()); + b.extend_from_slice(&0u32.to_be_bytes()); + b.extend_from_slice(&0u32.to_be_bytes()); + b.extend_from_slice(&0u32.to_be_bytes()); + b.extend_from_slice(&2u16.to_be_bytes()); + b + } + + #[tokio::test(flavor = "current_thread")] + async fn scan_pinning_takes_the_position_it_finds() { + let (_tmp, path) = spawn_worker(vec![ + Some(hello_body(PROTO_VERSION, PROJECTION_VERSION)), + Some(scan_body(0x4000, 0x4000)), + ]); + + let bridge = Bridge::connect(&path).await.unwrap(); + let res = bridge + .scan_pinning(Catalog::Namespace, 0, &[]) + .await + .expect("start == end pins"); + assert_eq!(res.replay_lsn_end, 0x4000); + assert_eq!(bridge.stats.scan_replay_moved.load(Ordering::Relaxed), 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn scan_pinning_rejects_a_move_inside_one_scan() { + let (_tmp, path) = spawn_worker(vec![ + Some(hello_body(PROTO_VERSION, PROJECTION_VERSION)), + Some(scan_body(0x4000, 0x5000)), + ]); + + let bridge = Bridge::connect(&path).await.unwrap(); + let err = bridge + .scan_pinning(Catalog::Namespace, 0, &[]) + .await + .unwrap_err(); + assert!( + matches!( + err, + BridgeError::ReplayMismatch { + expected: 0x4000, + end: 0x5000, + .. + } + ), + "got {err:?}" + ); + assert_eq!(bridge.stats.scan_replay_moved.load(Ordering::Relaxed), 1); + } + + /// A frame this side refuses never reaches the worker, so it must not read + /// as a dead socket and cost a redial + #[tokio::test(flavor = "current_thread")] + async fn oversize_request_keeps_the_connection() { + let (_tmp, path) = spawn_worker(vec![Some(hello_body(PROTO_VERSION, PROJECTION_VERSION))]); + + let bridge = Bridge::connect(&path).await.unwrap(); + let huge = vec![0u8; MAX_REQUEST_BYTES]; + let err = bridge.decode(&[(23, &huge)]).await.unwrap_err(); + assert!( + matches!(err, BridgeError::RequestTooLarge { .. }), + "got {err:?}" + ); + assert!(bridge.is_up()); + assert_eq!(bridge.stats.reconnects.load(Ordering::Relaxed), 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn remote_error_status_keeps_bridge_up() { + let mut body = vec![1u8]; + body.extend_from_slice(&5u32.to_be_bytes()); + body.extend_from_slice(b"nope!"); + let (_tmp, path) = spawn_worker(vec![ + Some(hello_body(PROTO_VERSION, PROJECTION_VERSION)), + Some(body), + ]); + + let bridge = Bridge::connect(&path).await.unwrap(); + let err = bridge.replay_lsn().await.unwrap_err(); + assert!(matches!(err, BridgeError::Remote(m) if m == "nope!")); + assert!(bridge.is_up()); + assert_eq!(bridge.stats.reconnects.load(Ordering::Relaxed), 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn dropped_connection_reconnects_and_retries() { + let mut lsn = vec![0u8]; + lsn.extend_from_slice(&0xdeadu64.to_be_bytes()); + let (_tmp, path) = spawn_worker(vec![ + Some(hello_body(PROTO_VERSION, PROJECTION_VERSION)), + // worker exits mid-request + None, + Some(hello_body(PROTO_VERSION, PROJECTION_VERSION)), + Some(lsn), + ]); + + let bridge = Bridge::connect(&path).await.unwrap(); + assert_eq!(bridge.replay_lsn().await.unwrap(), 0xdead); + assert_eq!(bridge.stats.reconnects.load(Ordering::Relaxed), 1); + assert!(bridge.is_up()); + } +} diff --git a/src/ops/metrics.rs b/src/ops/metrics.rs index 15ab2d3..905934a 100644 --- a/src/ops/metrics.rs +++ b/src/ops/metrics.rs @@ -146,6 +146,20 @@ pub struct MetricsSnapshot { pub oracle_resolved_total: u64, pub oracle_fallback_raw_total: u64, pub oracle_errors_total: u64, + /// 1 while the pgext bridge worker's last transport attempt succeeded, + /// 0 after failure. + pub bridge_up: u64, + /// Per-op, rendered `op=` labelled; order matches + /// [`OP_LABELS`](crate::ops::bridge::OP_LABELS). + pub bridge_requests_by_op: [u64; 4], + pub bridge_errors_by_op: [u64; 4], + pub bridge_request_nanos_by_op: [u64; 4], + pub bridge_reconnects_total: u64, + pub bridge_scan_rows_total: u64, + pub bridge_scan_subtrans_mismatch_total: u64, + pub bridge_scan_replay_moved_total: u64, + pub bridge_decode_items_total: u64, + pub bridge_decode_item_errors_total: u64, pub uptime_secs: u64, /// `source_received_lsn - min_apply_lsn` across active shadow walreceivers. /// Caller saturates to 0 when shadow is ahead; passes `source_received_lsn` @@ -178,6 +192,23 @@ pub struct MetricsSnapshot { pub desc_events_added_total: u64, pub desc_events_changed_total: u64, pub desc_events_dropped_total: u64, + /// Command boundaries read into the pending timeline + pub pending_captures_total: u64, + /// Descriptors read across those boundaries + pub pending_rels_total: u64, + /// Publication holds taken for a command boundary + pub pending_holds_total: u64, + /// Cumulative seconds parked in command-boundary holds + pub pending_hold_seconds_total: f64, + /// Pending slots folded into a commit batch + pub pending_entries_promoted_total: u64, + /// Pending slots dropped with an aborted tree + pub pending_entries_dropped_abort_total: u64, + /// Ambiguity intervals the timeline covered end to end + pub pending_ambiguities_suppressed_total: u64, + /// Transactions degraded to commit-time capture, by + /// [`DegradeReason::ALL`](crate::catalog::pending::DegradeReason::ALL) + pub pending_degraded_by_reason: [u64; 5], /// Descriptor-log index entries / tail bytes / batches pub desc_log_entries: u64, pub desc_log_tail_bytes: u64, @@ -646,6 +677,48 @@ pub fn render(snap: &MetricsSnapshot) -> String { "counter", snap.oracle_errors_total, ), + ( + "walshadow_bridge_up", + "1 while the pgext bridge worker answered the last request over its socket.", + "gauge", + snap.bridge_up, + ), + ( + "walshadow_bridge_reconnects_total", + "Bridge sockets redialled after a worker exit or transport error.", + "counter", + snap.bridge_reconnects_total, + ), + ( + "walshadow_bridge_scan_rows_total", + "Catalog rows the bridge's overlay scans returned.", + "counter", + snap.bridge_scan_rows_total, + ), + ( + "walshadow_bridge_scan_subtrans_mismatch_total", + "Overlay tuples whose writer did not resolve to the requested top xid; trusted as ours only on rel-scoped catalogs.", + "counter", + snap.bridge_scan_subtrans_mismatch_total, + ), + ( + "walshadow_bridge_scan_replay_moved_total", + "Bridge scans that found shadow replay off the position their read pinned; committed reads answer these off SQL instead.", + "counter", + snap.bridge_scan_replay_moved_total, + ), + ( + "walshadow_bridge_decode_items_total", + "Columns sent to the bridge for typoutput rendering.", + "counter", + snap.bridge_decode_items_total, + ), + ( + "walshadow_bridge_decode_item_errors_total", + "Bridge decode items that raised, leaving that column on the raw-bytes path.", + "counter", + snap.bridge_decode_item_errors_total, + ), ( "walshadow_uptime_seconds", "Seconds since the daemon began its status loop.", @@ -730,6 +803,42 @@ pub fn render(snap: &MetricsSnapshot) -> String { "counter", snap.desc_events_dropped_total, ), + ( + "walshadow_pending_captures_total", + "Command boundaries read into the pending catalog timeline.", + "counter", + snap.pending_captures_total, + ), + ( + "walshadow_pending_rels_total", + "Descriptors read across command boundaries.", + "counter", + snap.pending_rels_total, + ), + ( + "walshadow_pending_holds_total", + "Publication holds taken for a command boundary.", + "counter", + snap.pending_holds_total, + ), + ( + "walshadow_pending_entries_promoted_total", + "Pending slots folded into a commit's descriptor-log batch.", + "counter", + snap.pending_entries_promoted_total, + ), + ( + "walshadow_pending_entries_dropped_abort_total", + "Pending slots dropped with an aborted transaction tree.", + "counter", + snap.pending_entries_dropped_abort_total, + ), + ( + "walshadow_pending_ambiguities_suppressed_total", + "Ambiguity intervals the pending timeline covered end to end.", + "counter", + snap.pending_ambiguities_suppressed_total, + ), ( "walshadow_desc_log_entries", "Descriptor-log index entries resident.", @@ -956,6 +1065,34 @@ pub fn render(snap: &MetricsSnapshot) -> String { } } + // Bridge families, `op=` labelled + { + use crate::ops::bridge::OP_LABELS; + let name = "walshadow_bridge_requests_total"; + writeln!(s, "# HELP {name} Requests sent to the pgext bridge worker.").unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + for (op, v) in OP_LABELS.iter().zip(snap.bridge_requests_by_op) { + writeln!(s, "{name}{{op=\"{op}\"}} {v}").unwrap(); + } + let name = "walshadow_bridge_errors_total"; + writeln!( + s, + "# HELP {name} Bridge requests that failed, transport or worker-side." + ) + .unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + for (op, v) in OP_LABELS.iter().zip(snap.bridge_errors_by_op) { + writeln!(s, "{name}{{op=\"{op}\"}} {v}").unwrap(); + } + let name = "walshadow_bridge_request_seconds_total"; + writeln!(s, "# HELP {name} Wall time spent in bridge round trips.").unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + for (op, v) in OP_LABELS.iter().zip(snap.bridge_request_nanos_by_op) { + let secs = v as f64 / 1e9; + writeln!(s, "{name}{{op=\"{op}\"}} {secs}").unwrap(); + } + } + // Prom format accepts `+Inf` for unknown rate let name = "walshadow_shadow_apply_lag_seconds"; writeln!( @@ -978,6 +1115,27 @@ pub fn render(snap: &MetricsSnapshot) -> String { .unwrap(); writeln!(s, "# TYPE {name} counter").unwrap(); writeln!(s, "{name} {:.3}", snap.catalog_boundary_hold_seconds_total).unwrap(); + let name = "walshadow_pending_hold_seconds_total"; + writeln!( + s, + "# HELP {name} Cumulative seconds the pump parked in command-boundary holds.", + ) + .unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + writeln!(s, "{name} {:.3}", snap.pending_hold_seconds_total).unwrap(); + let name = "walshadow_pending_degraded_total"; + writeln!( + s, + "# HELP {name} Transactions degraded to commit-time capture, by reason.", + ) + .unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + for (reason, v) in crate::catalog::pending::DegradeReason::ALL + .iter() + .zip(snap.pending_degraded_by_reason) + { + writeln!(s, "{name}{{reason=\"{}\"}} {v}", reason.label()).unwrap(); + } let name = "walshadow_desc_capture_seconds_total"; writeln!( s, diff --git a/src/ops/mod.rs b/src/ops/mod.rs index 29c85d3..6b1ad1f 100644 --- a/src/ops/mod.rs +++ b/src/ops/mod.rs @@ -1,3 +1,4 @@ +pub mod bridge; pub mod control; pub mod metrics; pub mod oracle; diff --git a/src/ops/oracle.rs b/src/ops/oracle.rs index 4670d40..3a291d8 100644 --- a/src/ops/oracle.rs +++ b/src/ops/oracle.rs @@ -1,159 +1,117 @@ //! PgPending resolver backed by shadow PG. //! //! For varlena types outside walshadow's local matrix (`jsonb`, arrays, -//! `tsvector`, ranges, custom domains, ...), [`Oracle::resolve_pending`] runs -//! `walshadow_decode_disk(oid, bytea) -> text` on shadow PG, replacing -//! PgPending with [`ColumnValue::Text`]. Extension is optional: when absent -//! resolver returns `Ok(None)` and emitter ships raw on-disk bytes. +//! `tsvector`, ranges, custom domains, ...), [`Oracle::resolve_pending`] +//! renders on-disk bytes through PG's own `typoutput`, replacing PgPending with +//! [`ColumnValue::Text`]. //! -//! Separate `Client` from [`ShadowCatalog`](crate::catalog::shadow_catalog::ShadowCatalog): -//! oracle queries don't observe replay-LSN gating and mustn't pessimise the -//! catalog's query-one path. +//! The route is [`Bridge`], the preloaded worker: whole tuple in one round +//! trip, and no `pg_proc` row, so it works on a shadow whose catalog is a +//! read-only physical copy of source's. +//! +//! Daemon requires bridge at startup. Per-item and later transport failures +//! leave affected values as raw on-disk bytes. use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::time::Duration; - -use backon::{ExponentialBuilder, Retryable}; -use thiserror::Error; -use tokio::sync::Mutex; -use tokio_postgres::{Client, NoTls}; +use std::sync::atomic::{AtomicU64, Ordering}; use crate::decode::heap_decoder::ColumnValue; - -#[derive(Debug, Error)] -pub enum OracleError { - #[error("oracle pg connect: {0}")] - Connect(tokio_postgres::Error), - #[error("oracle pg query: {0}")] - Query(tokio_postgres::Error), -} +use crate::ops::bridge::{Bridge, DecodedItem}; crate::atomic_stats! { pub struct OracleStats { - /// `walshadow_decode_disk` calls returning a text payload + /// Columns rendered to text pub resolved, - /// `walshadow_decode_disk` calls returning NULL or absent-extension + /// Columns left as raw bytes: NULL or a per-item decode error pub fallback_raw, - /// SQL / connection errors, single bucket + /// Bridge transport errors, single bucket pub errors, } } pub struct Oracle { - client: Mutex>, - conninfo: String, - has_extension: AtomicBool, + bridge: Arc, pub stats: Arc, } impl Oracle { - /// Connect to shadow PG, probe for `walshadow` extension. Absence not a - /// failure: resolver returns `Ok(None)` thereafter (fall back to raw bytes). - pub async fn connect(conninfo: &str) -> Result { - let (client, connection) = tokio_postgres::connect(conninfo, NoTls) - .await - .map_err(OracleError::Connect)?; - tokio::spawn(async move { - let _ = connection.await; - }); - let has_ext = probe_extension(&client).await.unwrap_or(false); - Ok(Self { - client: Mutex::new(Some(client)), - conninfo: conninfo.to_owned(), - has_extension: AtomicBool::new(has_ext), + pub fn new(bridge: Arc) -> Self { + Self { + bridge, stats: Arc::new(OracleStats::default()), - }) + } } - /// Extension visible at connect time. Daemon status line surfaces this so - /// operators confirm the optional-extension contract on boot. - pub fn has_extension(&self) -> bool { - self.has_extension.load(Ordering::Relaxed) + pub fn bridge(&self) -> &Arc { + &self.bridge } - /// Mirrors [`ShadowCatalog`](crate::catalog::shadow_catalog::ShadowCatalog)'s - /// `query_one_retry`; duplicated here to keep oracle's pool independent. - async fn reconnect(&self) -> Result<(), OracleError> { - let (client, connection) = tokio_postgres::connect(&self.conninfo, NoTls) - .await - .map_err(OracleError::Connect)?; - tokio::spawn(async move { - let _ = connection.await; - }); - let has_ext = probe_extension(&client).await.unwrap_or(false); - *self.client.lock().await = Some(client); - self.has_extension.store(has_ext, Ordering::Relaxed); - Ok(()) + /// `None` when the worker is unreachable (counted via `stats.errors`) or + /// `typoutput` raised, so the emitter falls back to raw bytes. + pub async fn resolve_pending(&self, type_oid: u32, raw: &[u8]) -> Option { + self.decode_batch(&[(type_oid, raw)]).await?.pop()? } - /// `Ok(None)` when extension absent (emitter falls back to raw bytes) or on - /// transient error (counted via `stats.errors`). - pub async fn resolve_pending( - &self, - type_oid: u32, - raw: &[u8], - ) -> Result, OracleError> { - if !self.has_extension() { - self.stats.fallback_raw.fetch_add(1, Ordering::Relaxed); - return Ok(None); - } - let sql = "SELECT walshadow_decode_disk($1::oid, $2::bytea)"; - let typoid_param: u32 = type_oid; - let mut attempt = 0u8; - loop { - let row = { - let mut guard = self.client.lock().await; - let Some(client) = guard.as_mut() else { - return Ok(None); - }; - client.query_one(sql, &[&typoid_param, &raw]).await - }; - match row { - Ok(r) => { - let txt: Option = r.try_get(0).ok(); - if txt.is_some() { - self.stats.resolved.fetch_add(1, Ordering::Relaxed); - } else { - self.stats.fallback_raw.fetch_add(1, Ordering::Relaxed); - } - return Ok(txt); - } - Err(e) if attempt == 0 && e.is_closed() => { - attempt = 1; - let _ = self.reconnect().await; + /// Render a whole batch in one round trip. `None` means the transport + /// failed and nothing was answered; `Some` holds one slot per item, `None` + /// where `typoutput` raised. + async fn decode_batch(&self, items: &[(u32, &[u8])]) -> Option>> { + let answered = match self.bridge.decode(items).await { + Ok(v) if v.len() == items.len() => v, + Ok(_) | Err(_) => { + self.stats.errors.fetch_add(1, Ordering::Relaxed); + return None; + } + }; + let mut out = Vec::with_capacity(answered.len()); + for item in answered { + match item { + DecodedItem::Text(s) => { + self.stats.resolved.fetch_add(1, Ordering::Relaxed); + out.push(Some(s)); } - Err(_) => { - self.stats.errors.fetch_add(1, Ordering::Relaxed); - return Ok(None); + DecodedItem::Error(_) => { + self.stats.fallback_raw.fetch_add(1, Ordering::Relaxed); + out.push(None); } } } + Some(out) } } -async fn probe_extension(client: &Client) -> Result { - let row = client - .query_one( - "SELECT EXISTS(SELECT 1 FROM pg_proc WHERE proname = 'walshadow_decode_disk')", - &[], - ) - .await - .map_err(OracleError::Query)?; - Ok(row.try_get::<_, bool>(0).unwrap_or(false)) -} - /// PgPending → Text on success; on fall-back PgPending stays put and emitter /// writes raw bytes via `encode_value`. +/// +/// Whole tuple costs one round trip. pub async fn resolve_pending_tuple(oracle: &Oracle, columns: &mut [Option]) { + let answered = { + let items: Vec<(u32, &[u8])> = columns + .iter() + .filter_map(|col| match col { + Some(ColumnValue::PgPending { type_oid, raw }) + | Some(ColumnValue::Unsupported { type_oid, raw }) => { + Some((*type_oid, raw.as_slice())) + } + _ => None, + }) + .collect(); + if items.is_empty() { + return; + } + oracle.decode_batch(&items).await + }; + let Some(answered) = answered else { + return; + }; + let mut next = answered.into_iter(); for col in columns.iter_mut() { - let (Some(ColumnValue::PgPending { type_oid, raw }) - | Some(ColumnValue::Unsupported { type_oid, raw })) = col + let (Some(ColumnValue::PgPending { .. }) | Some(ColumnValue::Unsupported { .. })) = col else { continue; }; - let resolved = oracle.resolve_pending(*type_oid, raw.as_slice()).await; - if let Ok(Some(s)) = resolved { + // Counts matched at request time, so the iterator cannot run dry + if let Some(Some(s)) = next.next() { *col = Some(ColumnValue::Text(s)); } } @@ -177,22 +135,6 @@ impl OracleStats { } } -/// Connect with a timeout budget so a still-warming shadow doesn't pin the -/// daemon at boot. Matches the catalog's -/// [`with_transient_retry`](crate::catalog::shadow_catalog::with_transient_retry) shape. -pub async fn connect_with_budget(conninfo: &str, budget: Duration) -> Result { - let deadline = tokio::time::Instant::now() + budget; - (|| Oracle::connect(conninfo)) - .retry( - ExponentialBuilder::default() - .with_min_delay(Duration::from_millis(100)) - .with_max_delay(Duration::from_secs(1)) - .without_max_times(), - ) - .when(move |_: &OracleError| tokio::time::Instant::now() < deadline) - .await -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/record.rs b/src/record.rs index ba42d52..54b8312 100644 --- a/src/record.rs +++ b/src/record.rs @@ -52,24 +52,50 @@ pub enum Route { ToDecoder, } -/// One catalog-mutating commit: what descriptor capture needs to enumerate -/// the affected relations. Built by the filter at the commit record. +/// Which sample point a boundary is. Both park publication until shadow +/// replays through `next_lsn`; what capture reads there differs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BoundaryKind { + /// Commit of a catalog-mutating xact: the committed shape, durable + #[default] + Commit, + /// `XLOG_XACT_INVALIDATIONS` inside a dirty xact, i.e. a + /// `CommandCounterIncrement`. A relation's layout cannot move except at + /// one, so these are exactly the sample points a mid-xact descriptor + /// needs. What capture reads is the writing transaction's own + /// uncommitted rows: xid-scoped and speculative until its commit + Command { + /// (Sub)xid that logged the invalidations + writer_xid: u32, + }, +} + +/// One catalog boundary: what descriptor capture needs to enumerate the +/// affected relations. Built by the filter at the commit record, or at a +/// command boundary inside a catalog-dirty xact. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct BoundaryInfo { /// Xid the xact's buffered work drains under: prepared xid for - /// COMMIT/ABORT PREPARED (header xid is 0 there), else header xid + /// COMMIT/ABORT PREPARED (header xid is 0 there), else header xid. At a + /// command boundary, the dirty tree's root as the pump knows it pub drain_xid: u32, /// First catalog-touching record LSN across the xact tree; valid_from /// fallback when no per-oid source is sharper pub tree_first_touch: u64, /// Dirty-tracker pg_class decodes ∪ commit relcache invals (local db, - /// user oids) + /// user oids). At a command boundary, that command's relcache invals: + /// a relation absent from them did not change shape at this boundary pub oids: Vec, /// relId==0 whole-relcache inval, or a write to a descriptor-feeding /// catalog whose changes relcache invals don't enumerate (pg_namespace: /// namespace rename changes every embedded namespace text with zero /// per-relation invals) pub capture_all: bool, + pub kind: BoundaryKind, + /// Tree members the filter drained at this commit, so promotion finds + /// pending state a late `XLOG_XACT_ASSIGNMENT` left keyed under a + /// subxid. Empty at a command boundary + pub members: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -106,6 +132,10 @@ pub struct Record<'a> { pub catalog_boundary: bool, /// Capture input for a catalog boundary; `Some` iff `catalog_boundary` pub boundary_info: Option>, + /// Abort of a catalog-dirty tree: every member the filter drained. + /// Pending catalog slots those xids wrote drop here, on the pump, ahead + /// of any later boundary that would promote them into the durable log + pub aborted_tree: Option>>, /// Record's xact tree wrote catalog state earlier in the stream: /// decoder holds raw instead of decoding with live descriptors, /// commit-time capture publishes the layout this tuple was written @@ -199,6 +229,7 @@ impl RecordSink for CollectingRecordSink { route: record.route, catalog_boundary: record.catalog_boundary, boundary_info: record.boundary_info.clone(), + aborted_tree: record.aborted_tree.clone(), defer_catalog_decode: record.defer_catalog_decode, }); Ok(()) diff --git a/src/source/boundary_hold.rs b/src/source/boundary_hold.rs index 02440a8..52437e4 100644 --- a/src/source/boundary_hold.rs +++ b/src/source/boundary_hold.rs @@ -31,7 +31,7 @@ use std::time::{Duration, Instant}; use tokio::sync::Mutex; -use crate::record::{Record, RecordSink, SinkError}; +use crate::record::{BoundaryKind, Record, RecordSink, SinkError}; use crate::source::catalog_capture::CatalogCapture; use crate::source::queueing_record_sink::QueueingRecordSink; use crate::source::shadow_stream::ShadowStreamState; @@ -153,6 +153,17 @@ impl CatalogBoundaryGate { /// descriptors against the caught-up shadow, then forward the commit record /// — so by the time the worker drains the xact, its schema events are /// already attached and the log batch is durable. +/// +/// A command boundary +/// ([`BoundaryKind::Command`]) reuses +/// that sequence unchanged, and capture reads the transaction's own +/// uncommitted rows there. Capture declines the ones its cost controls rule +/// out, before the park — a boundary nothing will read is a stall for +/// nothing. +/// +/// Aborts drop their speculative slots here rather than at the drain: the +/// pump runs ahead of the decoder, so a later commit could otherwise promote +/// a rolled-back subtree's shapes into the durable log. pub struct BoundaryHoldSink { pub inner: QueueingRecordSink, pub gate: CatalogBoundaryGate, @@ -197,14 +208,29 @@ impl RecordSink for BoundaryHoldSink { record: &'a Record<'a>, ) -> Pin> + Send + 'a>> { Box::pin(async move { + if let (Some(capture), Some(members)) = (&self.capture, &record.aborted_tree) { + capture.forget_aborted(members); + } if !record.catalog_boundary { return self.inner.on_record(record).await; } + let boundary = record.boundary_info.clone(); + let park = match (&self.capture, &boundary) { + (Some(capture), Some(info)) => capture.admits(info, record.next_lsn), + // Hold-only harness: a command boundary nobody reads is a + // stall for nothing + (None, Some(info)) => matches!(info.kind, BoundaryKind::Commit), + _ => true, + }; + if !park { + return self.inner.on_record(record).await; + } // Ship + flush the xact's predecessors before parking; the // commit itself forwards only after capture so the worker's // drain finds events already attached self.inner.flush().await?; let inner = &self.inner; + let parked = Instant::now(); if let Err(hold_err) = self .gate .hold(record.source_lsn, record.next_lsn, || inner.worker_alive()) @@ -215,7 +241,8 @@ impl RecordSink for BoundaryHoldSink { self.inner.flush().await?; return Err(hold_err); } - if let (Some(capture), Some(info)) = (&self.capture, &record.boundary_info) { + if let (Some(capture), Some(info)) = (&self.capture, &boundary) { + capture.charge_hold(info, parked.elapsed()); capture .capture_boundary(info, record.source_lsn, record.next_lsn) .await?; @@ -394,6 +421,29 @@ mod tests { sink.close().await.expect("close"); } + #[tokio::test] + async fn sink_never_parks_at_a_command_boundary_nobody_captures() { + // Hold-only harness: the pending timeline is what a command + // boundary exists for, so with no capture there is nothing to wait + // on. No walreceiver here, so a park would time out + let q = QueueingRecordSink::spawn(CountingRecordSink::default(), 4, 16, None); + let gate = gate_with(state(), Duration::from_millis(10)); + let mut sink = BoundaryHoldSink::new(q, gate); + let rec = Record { + source_lsn: 0x1F00, + next_lsn: 0x2000, + catalog_boundary: true, + boundary_info: Some(Arc::new(crate::record::BoundaryInfo { + kind: BoundaryKind::Command { writer_xid: 7 }, + ..Default::default() + })), + ..Default::default() + }; + sink.on_record(&rec).await.expect("no park"); + assert_eq!(sink.gate.stats.failures.load(Ordering::Relaxed), 0); + sink.close().await.expect("close"); + } + #[tokio::test] async fn sink_boundary_flushes_partial_batch_and_releases() { // batch_size 64 with one record: without the forced flush the diff --git a/src/source/catalog_capture.rs b/src/source/catalog_capture.rs index 9c3deab..d891239 100644 --- a/src/source/catalog_capture.rs +++ b/src/source/catalog_capture.rs @@ -31,9 +31,19 @@ //! Rotations skip the check: the rewrite emits final-layout tuples and //! superseded-generation rows retire with the old rfn. Fresh generations //! have no covered predecessor to compare. +//! +//! Command boundaries ([`crate::record::BoundaryKind::Command`]) sample the +//! same relations mid-transaction, off the writing transaction's own +//! uncommitted rows, into +//! [`PendingCatalog`]. Nothing there +//! is durable until this xact's commit folds those slots into the batch at +//! their own positions, which is also what shrinks the fence: promoted slots +//! are exact shapes at exact positions, so the ambiguity survives only over +//! the run before the first of them, and rows past it decode. use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; use tokio_postgres::types::Oid; use walrus::pg::walparser::RelFileNode; @@ -43,9 +53,11 @@ use crate::catalog::desc_log::{ Ambiguity, AmbiguityReason, AmbiguityScope, BatchRecord, DescriptorLog, LogEntry, LogValue, ObservationKind, RelationObservation, }; -use crate::catalog::shadow_catalog::ShadowCatalog; +use crate::catalog::pending::{DegradeReason, PendingCatalog, PendingSlot}; +use crate::catalog::shadow_catalog::{CatalogError, ShadowCatalog}; use crate::filter::SmgrMarkers; -use crate::record::{BoundaryInfo, SinkError}; +use crate::ops::bridge::BridgeError; +use crate::record::{BoundaryInfo, BoundaryKind, SinkError}; use crate::schema::{RelDescriptor, SchemaEvent, compute_schema_diff}; use crate::xact::xact_buffer::XactBuffer; @@ -67,16 +79,66 @@ crate::atomic_stats! { pub events_dropped, /// Ambiguity intervals published for unproven in-place changes pub ambiguities_published, + /// Unproven in-place changes whose whole interval the pending + /// timeline answered for, so the commit published no fence + pub ambiguities_suppressed, + /// Command boundaries captured into the pending timeline + pub pending_captures, + /// Descriptors read across those captures + pub pending_rels, + /// Publication holds taken for a command boundary + pub pending_holds, + /// Cumulative nanos parked in command-boundary holds + pub pending_hold_nanos, + /// Pending slots folded into a commit batch + pub pending_entries_promoted, + /// Pending slots dropped with an aborted tree + pub pending_entries_dropped_abort, + /// Transactions degraded to commit-time capture, by reason + pub pending_degraded: [std::sync::atomic::AtomicU64; DegradeReason::ALL.len()], /// Capture duration, nanos (inside the boundary hold) pub capture_nanos, } } +impl CaptureStats { + fn count_degrade(&self, reason: DegradeReason) { + let at = DegradeReason::ALL + .iter() + .position(|r| *r == reason) + .expect("reason is one of ALL"); + self.pending_degraded[at].fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } +} + +/// Cost controls on command-boundary capture. Every refusal degrades the +/// transaction to commit-time capture, which is sound +#[derive(Debug, Clone, Copy)] +pub struct PendingCaptureConfig { + /// Boundaries one transaction may hold for + pub max_boundaries_per_xact: u32, + /// Cumulative parked time one transaction may cost + pub max_hold_per_xact: Duration, +} + +impl Default for PendingCaptureConfig { + fn default() -> Self { + Self { + max_boundaries_per_xact: 64, + max_hold_per_xact: Duration::from_secs(10), + } + } +} + pub struct CatalogCapture { log: Arc, catalog: Arc>, buffer: Arc>, markers: Arc>, + /// Speculative per-transaction catalog state, written here at command + /// boundaries and read by the commit drain + pending: Arc, + pending_cfg: PendingCaptureConfig, stats: Arc, } @@ -92,12 +154,16 @@ impl CatalogCapture { catalog: Arc>, buffer: Arc>, markers: Arc>, + pending: Arc, + pending_cfg: PendingCaptureConfig, ) -> Self { Self { log, catalog, buffer, markers, + pending, + pending_cfg, stats: Arc::new(CaptureStats::default()), } } @@ -106,6 +172,81 @@ impl CatalogCapture { self.stats.clone() } + /// Whether the pump should park for this boundary. A commit always + /// bounds; a command boundary the caps or the inval set rule out costs + /// nothing and is skipped before the hold + pub fn admits(&self, info: &BoundaryInfo, next_lsn: u64) -> bool { + let BoundaryKind::Command { .. } = info.kind else { + return true; + }; + // Aligned-prefix re-read: shadow replayed past this point long ago, + // so the scan could only answer for where replay is now. Degrades + // rather than skips silently — an xact straddling the horizon would + // otherwise claim coverage over boundaries nothing read + if next_lsn <= self.log.covered_through() { + if self + .pending + .degrade(info.drain_xid, DegradeReason::ReplayMismatch) + { + self.stats.count_degrade(DegradeReason::ReplayMismatch); + } + return false; + } + // Whole-relcache flush or a namespace catcache hit names no + // relations, and a full catalog scan per command is the one shape + // that makes holds expensive + if info.capture_all { + if self + .pending + .degrade(info.drain_xid, DegradeReason::CaptureAll) + { + self.stats.count_degrade(DegradeReason::CaptureAll); + } + return false; + } + if info.oids.is_empty() { + return false; + } + match self.pending.admit( + info.drain_xid, + self.pending_cfg.max_boundaries_per_xact, + self.pending_cfg.max_hold_per_xact.as_nanos() as u64, + ) { + Ok(()) => true, + Err(reason) => { + if let Some(reason) = reason { + self.stats.count_degrade(reason); + } + false + } + } + } + + /// Charge a released command-boundary hold against the transaction's + /// cumulative budget + pub fn charge_hold(&self, info: &BoundaryInfo, held: Duration) { + use std::sync::atomic::Ordering::Relaxed; + let BoundaryKind::Command { .. } = info.kind else { + return; + }; + let nanos = held.as_nanos() as u64; + self.stats.pending_holds.fetch_add(1, Relaxed); + self.stats.pending_hold_nanos.fetch_add(nanos, Relaxed); + self.pending.charge_hold(info.drain_xid, nanos); + } + + /// Aborted tree: its speculative slots die here, on the pump, before any + /// later boundary could promote them + pub fn forget_aborted(&self, members: &[u32]) { + use std::sync::atomic::Ordering::Relaxed; + let dropped = self.pending.forget_members(members); + if dropped > 0 { + self.stats + .pending_entries_dropped_abort + .fetch_add(dropped as u64, Relaxed); + } + } + pub async fn capture_boundary( &self, info: &BoundaryInfo, @@ -114,6 +255,18 @@ impl CatalogCapture { ) -> Result<(), SinkError> { use std::sync::atomic::Ordering::Relaxed; let start = std::time::Instant::now(); + if let BoundaryKind::Command { writer_xid } = info.kind { + let out = self.capture_command(info, writer_xid, next_lsn).await; + self.stats + .capture_nanos + .fetch_add(start.elapsed().as_nanos() as u64, Relaxed); + return out; + } + // Late `XLOG_XACT_ASSIGNMENT` leaves a subxact's slots under its own + // key; the commit's member list is the first place the whole tree is + // named, so fold before anything reads the timeline — including the + // paths below that capture nothing + self.pending.consolidate(info.drain_xid, &info.members); if next_lsn <= self.log.covered_through() { self.stats.skipped_covered.fetch_add(1, Relaxed); return Ok(()); @@ -141,12 +294,28 @@ impl CatalogCapture { Ok(()) } - /// Derive a stored batch's events against each oid's historical - /// predecessor (never the loaded head — boot loads the whole log - /// before the WAL re-read). + /// Derive a batch's events against each oid's historical predecessor + /// (never the loaded head — boot loads the whole log before the WAL + /// re-read). Live capture runs this over the batch it just appended, so + /// a boundary replayed from the log yields the same events by + /// construction. + /// + /// One event per oid: promoted command-boundary entries are intermediate + /// versions of the same commit, and capture always appends the commit + /// shape after them, so the oid's last non-`Retired` entry is the shape + /// CH is told about. fn replay_events(&self, batch: &BatchRecord) -> Vec { + let mut last_for_oid: HashMap = HashMap::new(); + for (at, entry) in batch.entries.iter().enumerate() { + if !matches!(entry.value, LogValue::Retired) { + last_for_oid.insert(entry.oid, at); + } + } let mut out = Vec::new(); - for entry in &batch.entries { + for (at, entry) in batch.entries.iter().enumerate() { + if last_for_oid.get(&entry.oid) != Some(&at) { + continue; + } let pred = self.log.predecessor_before(entry.oid, batch.captured_at); let pred_desc = pred.as_ref().and_then(|p| match &p.value { LogValue::Present(d) => Some(d.clone()), @@ -178,6 +347,79 @@ impl CatalogCapture { out } + /// One command boundary: the shapes the writing transaction sees at + /// `next_lsn`, where shadow's replay is parked. Best effort — every + /// failure degrades the transaction to commit-time capture instead of + /// poisoning the stream + async fn capture_command( + &self, + info: &BoundaryInfo, + writer_xid: u32, + next_lsn: u64, + ) -> Result<(), SinkError> { + use std::sync::atomic::Ordering::Relaxed; + let top_xid = info.drain_xid; + let oids: Vec = info.oids.iter().map(|a| a.oid).collect(); + let read = { + let mut cat = self.catalog.lock().await; + cat.fetch_overlay_descriptors(&oids, top_xid, next_lsn) + .await + }; + let descs = match read { + Ok(descs) => descs, + Err(e) => { + let reason = degrade_reason(&e); + if self.pending.degrade(top_xid, reason) { + self.stats.count_degrade(reason); + } + tracing::warn!( + target: "walshadow::desc_log", + xid = top_xid, + boundary = format_args!("{next_lsn:#X}"), + reason = reason.label(), + err = %e, + "pending capture degraded, commit-time capture stands", + ); + return Ok(()); + } + }; + let slots: Vec = descs + .into_iter() + .filter(|d| matches!(d.kind, 'r' | 'p' | 'm' | 't')) + .map(|desc| { + // A relation born in this xact cannot have changed layout + // before its first command boundary, and its rows start at + // the smgr create — the exact lower bound `CREATE TABLE AS` + // needs, where one command writes storage, fills it, and + // only then hits its CCI. Only the first shape seen on that + // generation may claim the marker; a later boundary + // answering from it would bury the earlier one + let pred = self.log.predecessor_before(desc.oid, next_lsn); + let pred_rfn = pred.as_ref().and_then(|p| match &p.value { + LogValue::Present(d) => Some(d.rfn), + _ => None, + }); + let first_seen = + pred_rfn != Some(desc.rfn) && !self.pending.has_slots(top_xid, desc.rfn); + let valid_from = first_seen + .then(|| self.marker_for(desc.rfn)) + .flatten() + .unwrap_or(next_lsn); + PendingSlot { + valid_from, + writer_xid, + desc: Arc::new(desc), + } + }) + .collect(); + self.stats.pending_captures.fetch_add(1, Relaxed); + self.stats + .pending_rels + .fetch_add(slots.len() as u64, Relaxed); + self.pending.record(top_xid, slots); + Ok(()) + } + async fn sql_capture( &self, info: &BoundaryInfo, @@ -264,9 +506,13 @@ impl CatalogCapture { }); } + // Command boundaries this xact held for. Slots are exact shapes at + // exact positions, so they promote even from a degraded xact; what + // degradation costs is the coverage claim, not the evidence + let promoted = self.pending.promoted(info.drain_xid); + let mut entries: Vec> = Vec::new(); let mut ambiguities: Vec> = Vec::new(); - let mut events: Vec = Vec::new(); for oid in expected { let pred = self.log.predecessor_before(oid, next_lsn); let pred_desc = pred.as_ref().and_then(|p| match &p.value { @@ -308,16 +554,39 @@ impl CatalogCapture { value: LogValue::Retired, })); } + let slots = promoted.as_ref().map_or(&[][..], |p| p.slots_for(oid)); let changed = pred_desc.as_deref() != Some(desc); - if changed { + // A slot-carrying oid always lands its commit shape too, + // even unchanged: the batch's last entry for an oid is + // what event derivation reads, and an intermediate + // version is not what CH is told about + if changed || !slots.is_empty() { // In-place transition must prove the final // descriptor reads the whole dirty interval; a // rotation's rewrite emits final-layout tuples and // a fresh generation has no covered predecessor let mut valid_from = first_touch; - if !rotated && let Some(pred) = pred_desc.as_deref() { - let (from, published, incompat) = - in_place_verdict(pred, desc, oid, first_touch, next_lsn); + if changed + && !rotated + && let Some(pred) = pred_desc.as_deref() + { + // The timeline answers from the first boundary + // that named the relation on, so only the run + // up to it stays fenced. Rows there predate the + // transaction's first `CommandCounterIncrement` + // and so were written under the predecessor, + // which is what the fence protects: the exact + // mutation position inside that run is still + // unknown + let covered_from = promoted.as_ref().and_then(|p| p.coverage_from(oid)); + let (from, published, incompat) = in_place_verdict( + pred, + desc, + oid, + first_touch, + next_lsn, + covered_from, + ); valid_from = from; if let Some(why) = incompat.filter(|i| !i.is_physical()) { tracing::debug!( @@ -328,7 +597,12 @@ impl CatalogCapture { "in-place change reads identically, bias-early kept", ); } - if !published.is_empty() { + if published.is_empty() + && covered_from.is_some() + && incompat.is_some_and(|i| i.is_physical()) + { + self.stats.ambiguities_suppressed.fetch_add(1, Relaxed); + } else if !published.is_empty() { tracing::warn!( target: "walshadow::desc_log", oid, @@ -344,19 +618,29 @@ impl CatalogCapture { ambiguities.extend(published.into_iter().map(Arc::new)); } } + // Promoted first, commit shape last: the batch reads + // as the transaction's own sequence of shapes + let mut prev = pred_desc.as_deref(); + for slot in slots { + if prev == Some(slot.desc.as_ref()) { + continue; + } + prev = Some(slot.desc.as_ref()); + entries.push(Arc::new(LogEntry { + valid_from: slot.valid_from, + oid, + rfn: slot.desc.rfn, + value: LogValue::Present(slot.desc.clone()), + })); + self.stats.pending_entries_promoted.fetch_add(1, Relaxed); + } let desc = Arc::new(desc.clone()); entries.push(Arc::new(LogEntry { valid_from, oid, rfn: desc.rfn, - value: LogValue::Present(desc.clone()), + value: LogValue::Present(desc), })); - if let Some(event) = diff_event(pred_desc.as_deref(), &desc) { - events.push(PendingEvent { - lsn: valid_from, - event, - }); - } } } None => { @@ -367,13 +651,6 @@ impl CatalogCapture { rfn: old.rfn, value: LogValue::Dropped, })); - events.push(PendingEvent { - lsn: next_lsn, - event: SchemaEvent::Dropped { - oid, - rel_name: old.rel_name.clone(), - }, - }); } } } @@ -387,17 +664,22 @@ impl CatalogCapture { }); // Zero-entry stub still appends: boot replay must distinguish // "captured, no shape change" from "never captured" + let batch = BatchRecord { + captured_at: next_lsn, + commit_lsn, + observations, + ambiguities, + entries, + }; self.log - .append_batch(BatchRecord { - captured_at: next_lsn, - commit_lsn, - observations, - ambiguities, - entries, - }) + .append_batch(batch.clone()) .await .map_err(|e| SinkError::Other(format!("descriptor log append: {e}")))?; - Ok(events) + // Events off the appended batch, the same derivation a restart runs: + // one boundary cannot mean two schema histories. + // `predecessor_before` reads strictly older batches, so the append + // above is invisible to it + Ok(self.replay_events(&batch)) } /// Markers key physical WAL locators; descriptor rfns are resolved to @@ -407,6 +689,16 @@ impl CatalogCapture { } } +/// Why an overlay read failed, as the transaction's degrade reason. A +/// replay position other than the boundary is its own case: shadow cannot +/// rewind, so nothing later re-reads that point +fn degrade_reason(err: &CatalogError) -> DegradeReason { + match err { + CatalogError::Bridge(BridgeError::ReplayMismatch { .. }) => DegradeReason::ReplayMismatch, + _ => DegradeReason::QueryError, + } +} + /// Entry LSN for an in-place final version + the ambiguities covering the /// dirty interval when the final descriptor is not a proven reader of it. /// First pg_class touch bounds the interval; exact change positions inside @@ -414,6 +706,12 @@ impl CatalogCapture { /// version answers from `next_lsn`, keeping the post-commit descriptor /// usable over the ambiguous interval. /// +/// `covered_from` is where this transaction's own timeline starts answering +/// for the relation, which ends the fence there: the promoted slots are +/// exact shapes at exact positions, so only the run before the first of them +/// has an unknown mutation position left in it. An empty run publishes +/// nothing. +/// /// A benign reject (declared shape drifted, every byte reads the same) /// keeps bias-early and publishes nothing. A physical one publishes one /// interval per identity key so the rfn-keyed decode path and the oid-keyed @@ -425,6 +723,7 @@ fn in_place_verdict( oid: Oid, first_touch: u64, next_lsn: u64, + covered_from: Option, ) -> (u64, Vec, Option) { let Err(incompat) = crate::catalog::compat::compatible_reader(pred, fin) else { return (first_touch, Vec::new(), None); @@ -432,10 +731,14 @@ fn in_place_verdict( if !incompat.is_physical() { return (first_touch, Vec::new(), Some(incompat)); } + let through_lsn = covered_from.unwrap_or(next_lsn).min(next_lsn); + if through_lsn <= first_touch { + return (next_lsn, Vec::new(), Some(incompat)); + } let interval = |scope| Ambiguity { scope, from_lsn: first_touch, - through_lsn: next_lsn, + through_lsn, reason: AmbiguityReason::UnknownMutationPosition, }; ( @@ -507,7 +810,7 @@ mod tests { fn compatible_in_place_keeps_bias_early() { let pred = rel(23, 4, "t"); let fin = rel(23, 4, "renamed"); - let (from, published, incompat) = in_place_verdict(&pred, &fin, 42, 100, 500); + let (from, published, incompat) = in_place_verdict(&pred, &fin, 42, 100, 500, None); assert_eq!(from, 100); assert!(published.is_empty()); assert_eq!(incompat, None); @@ -518,7 +821,7 @@ mod tests { let pred = rel(1043, -1, "t"); let mut fin = rel(1043, -1, "t"); fin.attributes[0].typmod = 24; - let (from, published, incompat) = in_place_verdict(&pred, &fin, 42, 100, 500); + let (from, published, incompat) = in_place_verdict(&pred, &fin, 42, 100, 500, None); assert_eq!(from, 100, "read-identical drift keeps bias-early"); assert!(published.is_empty(), "no fence for a benign reject"); assert_eq!(incompat, Some(Incompat::Benign("typmod change"))); @@ -528,7 +831,7 @@ mod tests { fn physical_in_place_publishes_rfn_and_oid_siblings() { let pred = rel(23, 4, "t"); let fin = rel(20, 8, "t"); - let (from, published, incompat) = in_place_verdict(&pred, &fin, 42, 100, 500); + let (from, published, incompat) = in_place_verdict(&pred, &fin, 42, 100, 500, None); assert_eq!(from, 500, "final version serves post-commit rows only"); assert!(incompat.is_some_and(|i| i.is_physical())); // Fixed order: batch equality and digest gate append idempotency @@ -543,4 +846,46 @@ mod tests { assert_eq!(amb.reason, AmbiguityReason::UnknownMutationPosition); } } + + #[test] + fn pending_coverage_shrinks_the_fence_to_the_pre_boundary_run() { + let pred = rel(23, 4, "t"); + let fin = rel(20, 8, "t"); + let (from, published, _) = in_place_verdict(&pred, &fin, 42, 100, 500, Some(200)); + assert_eq!(from, 500); + assert_eq!(published.len(), 2, "both identity keys still filed"); + for amb in &published { + assert_eq!(amb.from_lsn, 100); + assert_eq!( + amb.through_lsn, 200, + "timeline answers from the first boundary on", + ); + } + } + + #[test] + fn coverage_from_the_first_touch_publishes_nothing() { + let pred = rel(23, 4, "t"); + let fin = rel(20, 8, "t"); + let (from, published, incompat) = in_place_verdict(&pred, &fin, 42, 100, 500, Some(100)); + assert_eq!(from, 500); + assert!(published.is_empty(), "empty run needs no fence"); + assert!(incompat.is_some_and(|i| i.is_physical())); + } + + #[test] + fn degrade_reason_separates_replay_mismatch_from_the_rest() { + assert_eq!( + degrade_reason(&CatalogError::Bridge(BridgeError::ReplayMismatch { + expected: 1, + start: 2, + end: 3, + })), + DegradeReason::ReplayMismatch, + ); + assert_eq!( + degrade_reason(&CatalogError::Parse("nope".into())), + DegradeReason::QueryError, + ); + } } diff --git a/src/source/queueing_record_sink.rs b/src/source/queueing_record_sink.rs index 27690dd..2a95f54 100644 --- a/src/source/queueing_record_sink.rs +++ b/src/source/queueing_record_sink.rs @@ -347,6 +347,7 @@ impl RecordSink for QueueingRecordSink { route: record.route, catalog_boundary: record.catalog_boundary, boundary_info: record.boundary_info.clone(), + aborted_tree: record.aborted_tree.clone(), defer_catalog_decode: record.defer_catalog_decode, }); if self.buf.len() >= self.batch_size { diff --git a/src/source/wal_stream.rs b/src/source/wal_stream.rs index a160437..eca77d5 100644 --- a/src/source/wal_stream.rs +++ b/src/source/wal_stream.rs @@ -327,6 +327,7 @@ impl WalStream { route, catalog_boundary: verdict.catalog_boundary, boundary_info: verdict.boundary, + aborted_tree: verdict.aborted_tree, defer_catalog_decode: verdict.defer_catalog_decode, }; if let Some(sink) = record_sink.as_deref_mut() { diff --git a/src/xact/xact_buffer.rs b/src/xact/xact_buffer.rs index c3650ca..1bc8dab 100644 --- a/src/xact/xact_buffer.rs +++ b/src/xact/xact_buffer.rs @@ -47,6 +47,7 @@ use tracing::Instrument; use walrus::pg::walparser::{RelFileNode, RmId}; use crate::catalog::desc_log::{Ambiguity, DescriptorLog, LookupResult}; +use crate::catalog::pending::{PendingCatalog, PendingSlot}; use crate::decode::decoder_sink::{DecoderSinkError, DecoderStats}; use crate::decode::heap_decoder::{ ColumnValue, DecodedHeap, DescribedHeap, HeapOp, ToastPointer, decode_heap_record, @@ -560,6 +561,11 @@ pub enum StashOutcome { /// Resolution happens at the commit's `next_lsn`, past every /// interval's end, so the fence is applied per record at fold fence: Vec>, + /// Shapes this xact's own command boundaries saw, ascending. A + /// record inside one decodes under it rather than under the + /// commit-time descriptor: same-xact `ALTER` then `INSERT` is two + /// layouts, and only the timeline separates them + pending: Vec, }, } @@ -582,9 +588,14 @@ pub struct StashResolution { /// `next_lsn`, which every interval published by this commit ends at, so /// each filenode's overlapping intervals ride the `Ordinary` outcome and /// the drain's `fold_raw_ordinary` fences the records actually inside one. +/// +/// `pending` is this tree's command-boundary timeline, already consolidated +/// under `top_xid` by capture. It rides the outcome for the same reason the +/// fence does: one lookup at commit, one verdict per record at fold. pub async fn resolve_stash( buffer: &Arc>, log: &DescriptorLog, + pending: &PendingCatalog, top_xid: u32, subxids: &[u32], next_lsn: u64, @@ -648,6 +659,7 @@ pub async fn resolve_stash( rel, valid_from, fence, + pending: pending.chain(top_xid, rfn), }, ); } @@ -1794,8 +1806,21 @@ impl MergedDrain { rel, valid_from, fence, + pending, }) => { - let (rel, valid_from, fence) = (rel.clone(), *valid_from, fence.clone()); + // Shape this record was written under: the xact's own + // timeline where it covers the position, the commit-time + // resolution otherwise (records before the xact's first + // command boundary were written under the pre-xact shape, + // which bias-early capture already lands there) + let (rel, valid_from) = pending + .iter() + .rev() + .find(|s| s.valid_from <= raw.source_lsn) + .map_or((rel.clone(), *valid_from), |s| { + (s.desc.clone(), s.valid_from) + }); + let fence = fence.clone(); return self.fold_raw_ordinary(raw, rel, valid_from, &fence); } // No outcome = the filenode resolved Dropped / Retired / @@ -3211,12 +3236,40 @@ pub(crate) mod raw_fixtures { rel, valid_from: 0x50, fence, + pending: Vec::new(), }, ); b.pending_stash .insert(1, StashResolution { outcomes, stats }); } + /// `Ordinary` verdict carrying this xact's own command-boundary shapes, + /// as `resolve_stash` reads them off the pending catalog + pub(crate) fn inject_ordinary_pending( + b: &mut XactBuffer, + rfn: RelFileNode, + rel: Arc, + pending: Vec, + ) { + let mut outcomes = HashMap::new(); + outcomes.insert( + rfn, + StashOutcome::Ordinary { + rel, + valid_from: 0x50, + fence: Vec::new(), + pending, + }, + ); + b.pending_stash.insert( + 1, + StashResolution { + outcomes, + stats: None, + }, + ); + } + /// `[from, through)` interval over one filenode, the shape a physical /// in-place verdict publishes pub(crate) fn rfn_ambiguity( @@ -4404,6 +4457,7 @@ mod tests { resolve_stash( &buffer, &log, + &PendingCatalog::default(), 1, &[], 0x300, @@ -4679,6 +4733,63 @@ mod tests { drain.finish().await.unwrap(); } + /// Records fold under the shape their own position had: the xact's + /// command-boundary slot where one covers, the commit-time resolution + /// before the first boundary + #[tokio::test(flavor = "current_thread")] + async fn raw_records_fold_against_the_pending_timeline() { + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + // Commit shape has a second column the boundary shape lacks, so + // which descriptor decoded a record is visible in its column count + let boundary_shape = int4_descriptor(16417); + let mut commit_shape = (*boundary_shape).clone(); + let mut extra = commit_shape.attributes[0].clone(); + extra.attnum = 2; + extra.name = "added".into(); + commit_shape.attributes.push(extra); + let rfn = boundary_shape.rfn; + b.stash_raw(1, multi_insert_raw(1, 100, 16417, &[1])) + .await + .unwrap(); + b.stash_raw(1, multi_insert_raw(1, 300, 16417, &[2])) + .await + .unwrap(); + inject_ordinary_pending( + &mut b, + rfn, + Arc::new(commit_shape), + vec![PendingSlot { + valid_from: 200, + writer_xid: 1, + desc: boundary_shape, + }], + ); + let mut drain = b.drain_committed(1, 42, 0x2000, &[], false).await.unwrap(); + let batch = drain + .next_batch(8, usize::MAX, None) + .await + .unwrap() + .expect("one slice"); + let shapes: Vec<(u64, usize, u64)> = batch + .heaps + .iter() + .map(|h| { + ( + h.decoded.source_lsn, + h.descriptor.attributes.len(), + h.descriptor_valid_from, + ) + }) + .collect(); + assert_eq!( + shapes, + vec![(100, 2, 0x50), (300, 1, 200)], + "record before the first boundary keeps the commit resolution", + ); + drain.finish().await.unwrap(); + } + /// Writer xid reaches fanned-out heaps identically whether the raw /// stash drained from memory or from spill (v6 keeps xid on disk), and /// the pending queue's resident accounting fully releases diff --git a/tests/bin_stream_e2e.rs b/tests/bin_stream_e2e.rs index 74419b8..d905570 100644 --- a/tests/bin_stream_e2e.rs +++ b/tests/bin_stream_e2e.rs @@ -16,13 +16,13 @@ use std::fs; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpStream}; use std::os::unix::process::CommandExt; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::thread::sleep; use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; -use walshadow::shadow::{Shadow, ShadowConfig}; +use walshadow::shadow::{BridgeConf, Shadow, ShadowConfig}; // Reserved port slot for this test binary — 56170-range, distinct // from pipeline_e2e (56100) / bootstrap_*_e2e (56140) so concurrent `cargo test` @@ -52,6 +52,31 @@ fn pg_basebackup_available() -> bool { .unwrap_or(false) } +/// Build tree holding `walshadow.so`, fed to PG as `dynamic_library_path`. +/// Daemon dials the bridge worker at boot, so an unbuilt module is a failure, +/// not a reason to skip +fn pgext_dir() -> PathBuf { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("pgext"); + assert!( + dir.join("walshadow.so").is_file(), + "pgext/walshadow.so missing, run `make -C pgext`" + ); + dir +} + +/// Preload the bridge worker on this test's own shadow, listening where the +/// daemon dials by default (`/walshadow-bridge.sock`) +fn append_bridge_conf(data_dir: &Path, socket_dir: &Path, lib_dir: PathBuf) -> Result<()> { + let mut bridge = BridgeConf::in_dir(socket_dir); + bridge.library_dir = Some(lib_dir); + let conf = data_dir.join("postgresql.conf"); + fs::OpenOptions::new() + .append(true) + .open(&conf)? + .write_all(bridge.conf_text("postgres").as_bytes())?; + Ok(()) +} + fn make_pg(tmp: &tempfile::TempDir, name: &str, port: u16) -> Shadow { let mut cfg = ShadowConfig::new( tmp.path().join(format!("{name}-data")), @@ -207,6 +232,7 @@ async fn bin_stream_replicates_segments_and_serves_metrics() { eprintln!("skip: no pg_basebackup on PATH"); return; } + let bridge_lib_dir = pgext_dir(); let tmp = tempfile::tempdir().unwrap(); @@ -241,6 +267,7 @@ async fn bin_stream_replicates_segments_and_serves_metrics() { rewrite_for_shadow(&shadow_data, SHADOW_PORT, &shadow_sock).expect("retarget shadow conf"); enable_recovery(&shadow_data, &shadow_filter_dir, WALSENDER_PORT) .expect("enable shadow recovery"); + append_bridge_conf(&shadow_data, &shadow_sock, bridge_lib_dir).expect("preload bridge worker"); let mut shadow_cfg = ShadowConfig::new(shadow_data.clone(), shadow_filter_dir.clone()); shadow_cfg.port = SHADOW_PORT; @@ -596,6 +623,7 @@ async fn wire_drop_midsegment_shadow_resumes_streaming() { eprintln!("skip: PG binaries not on PATH"); return; } + let bridge_lib_dir = pgext_dir(); let tmp = tempfile::tempdir().unwrap(); let source = make_pg(&tmp, "wd-source", WD_SOURCE_PORT); @@ -622,6 +650,7 @@ async fn wire_drop_midsegment_shadow_resumes_streaming() { fs::create_dir_all(&shadow_sock).unwrap(); rewrite_for_shadow(&shadow_data, WD_SHADOW_PORT, &shadow_sock).expect("retarget shadow"); enable_recovery(&shadow_data, &filter_dir, WD_WALSENDER_PORT).expect("enable recovery"); + append_bridge_conf(&shadow_data, &shadow_sock, bridge_lib_dir).expect("preload bridge worker"); // Slow the walreceiver restart so killing it leaves a multi-second window in // which the writer advances the head — guaranteeing the reconnect lands // behind it (the gap). Overrides the 100ms from rewrite_for_shadow. diff --git a/tests/bridge.rs b/tests/bridge.rs new file mode 100644 index 0000000..26a105e --- /dev/null +++ b/tests/bridge.rs @@ -0,0 +1,984 @@ +//! pgext bridge worker, end to end over its unix socket. +//! +//! Needs `walshadow.so` built in `pgext/` (`make -C pgext`) against the same +//! PG major as `initdb` on PATH; fails when it isn't there. +//! +//! Drills cover bridge protocol and lifecycle: +//! +//! 1. `bridge_hello_and_decode_batch` — version gate, `REPLAY_LSN`, and a +//! `DECODE` batch where a truncated body and an unknown type oid return +//! item errors without costing their neighbours +//! 2. `bridge_decode_matches_typoutput` — every `ws_decode_datum_text` branch +//! (varlena, cstring, fixed by-value, fixed by-reference) against PG's own +//! rendering of the same value +//! 3. `bridge_scans_uncommitted_ddl` — `SCAN` against an open transaction: one +//! `pg_class` row per oid despite the superseded versions on the page, the +//! new column with its `attmissingval`, a rolled-back savepoint's column +//! absent, a relation created in-transaction visible by oid, a foreign +//! in-progress whole-catalog writer failing the scan as inconclusive, the +//! committed shape returned after `ROLLBACK`, and every handled error +//! leaving the same connection serving. Also the two arguments that turn +//! the same scan into a committed read: top xid 0, and no oid list, which +//! still projects `attnum >= 1` now that it is legal on every catalog +//! 4. `bridge_reconnects_after_worker_exit` — worker killed mid-flight, daemon +//! redials, no cluster restart; the restarted worker pins its decode output +//! GUCs over contrary database defaults +//! 5. `bridge_drops_bad_frames_per_connection` — oversize and zero-length +//! frames close only their own connection +//! 6. `bridge_error_frames_stay_parseable` — unknown opcode and trailing +//! request bytes answer one exactly-consumed error frame, same connection +//! serves the next request +//! 7. `bridge_overlay_descriptors_track_open_ddl` — mirroring statement, +//! committed scan and overlay scan agree for an untouched relation, then +//! overlay alone tracks an open transaction's added column, new relation, +//! schema, and type +//! 8. `bridge_committed_read_falls_back_when_replay_moves` — a committed read +//! whose pin breaks answers off the mirroring statement; the same break +//! fails an overlay read +//! +//! Clusters here are plain (non-recovery) PG. The overlay predicate keys off +//! xids, not recovery, so an open transaction exercises the same code a +//! standby's replaying transaction does. + +use std::fs; +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio_postgres::{Client, NoTls}; +use walshadow::bridge::{ + AttributeRow, Bridge, BridgeError, Catalog, ClassRow, DecodedItem, IndexRow, NamespaceRow, + PROJECTION_VERSION, PROTO_VERSION, +}; +use walshadow::pg::socket_conninfo; +use walshadow::schema::ReplIdent; +use walshadow::shadow::{BridgeConf, Shadow, ShadowConfig}; +use walshadow::shadow_catalog::{CatalogError, ShadowCatalog, ShadowCatalogConfig}; + +const BASE_PORT: u16 = 56401; + +fn pg_available() -> bool { + Command::new("initdb") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Build tree holding `walshadow.so`, fed to shadow as `dynamic_library_path`. +/// Module is not optional, so an unbuilt tree fails rather than skips +fn pgext_dir() -> PathBuf { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("pgext"); + assert!( + dir.join("walshadow.so").is_file(), + "pgext/walshadow.so missing, run `make -C pgext`" + ); + dir +} + +struct StopOnDrop { + sh: Shadow, +} + +impl Drop for StopOnDrop { + fn drop(&mut self) { + let _ = self.sh.stop(); + } +} + +fn start_pg(tmp: &tempfile::TempDir, port: u16) -> StopOnDrop { + let lib_dir = pgext_dir(); + let mut cfg = ShadowConfig::new(tmp.path().join("data"), tmp.path().join("filtered")); + cfg.port = port; + cfg.socket_dir = tmp.path().join("sock"); + cfg.ctl_timeout = Duration::from_secs(60); + let mut bridge = BridgeConf::in_dir(&cfg.socket_dir); + bridge.library_dir = Some(lib_dir); + cfg.bridge = Some(bridge); + fs::create_dir_all(&cfg.filter_out_dir).unwrap(); + fs::create_dir_all(&cfg.socket_dir).unwrap(); + + let sh = Shadow::new(cfg); + sh.initdb().expect("initdb"); + sh.write_base_conf().expect("write_base_conf"); + sh.start().expect("start"); + StopOnDrop { sh } +} + +async fn dial(sh: &Shadow) -> Bridge { + let path = sh.bridge_socket().expect("bridge configured"); + walshadow::bridge::connect_with_budget(path, Duration::from_secs(20)) + .await + .unwrap_or_else(|e| panic!("bridge connect on {}: {e}", path.display())) +} + +async fn connect_sql(sh: &Shadow) -> Client { + let conninfo = socket_conninfo( + sh.config().socket_dir.to_str().unwrap(), + sh.config().port, + &sh.config().user, + &sh.config().dbname, + ); + let (client, connection) = tokio_postgres::connect(&conninfo, NoTls) + .await + .expect("sql connect"); + tokio::spawn(async move { + let _ = connection.await; + }); + client +} + +async fn open_catalog(sh: &Shadow, bridge: Arc) -> ShadowCatalog { + let conninfo = socket_conninfo( + sh.config().socket_dir.to_str().unwrap(), + sh.config().port, + &sh.config().user, + &sh.config().dbname, + ); + ShadowCatalog::connect(&conninfo, ShadowCatalogConfig::default(), bridge) + .await + .expect("catalog connect") +} + +/// Catalog behind a worker whose replay position moves under every scan, so +/// every committed read it serves answers off the mirroring statement — what a +/// standby away from a publication hold looks like +async fn open_mirror_catalog(sh: &Shadow, sock: &Path) -> (Arc, ShadowCatalog) { + spawn_moving_worker(tokio::net::UnixListener::bind(sock).expect("bind stand-in")); + let bridge = Arc::new( + walshadow::bridge::connect_with_budget(sock, Duration::from_secs(5)) + .await + .expect("stand-in bridge"), + ); + let cat = open_catalog(sh, bridge.clone()).await; + (bridge, cat) +} + +async fn scalar(client: &Client, sql: &str) -> String { + client + .query_one(sql, &[]) + .await + .expect(sql) + .get::<_, String>(0) +} + +async fn oid_of(client: &Client, relname: &str) -> u32 { + scalar(client, &format!("SELECT '{relname}'::regclass::oid::text")) + .await + .parse() + .expect("oid") +} + +async fn oid_of_type(client: &Client, typname: &str) -> u32 { + scalar(client, &format!("SELECT '{typname}'::regtype::oid::text")) + .await + .parse() + .expect("type oid") +} + +/// `pg_current_xact_id` is xid8; the wire carries the 32-bit `TransactionId` +async fn top_xid(client: &Client) -> u32 { + scalar(client, "SELECT pg_current_xact_id()::text") + .await + .parse::() + .expect("xid8") as u32 +} + +/// Uncompressed on-disk varlena body, ie header already stripped +fn body(bytes: &[u8]) -> Vec { + bytes.to_vec() +} + +/// Short-form numeric for `42`: header 0x8000, one base-10000 digit +fn numeric_42() -> Vec { + let mut out = 0x8000u16.to_le_bytes().to_vec(); + out.extend_from_slice(&42i16.to_le_bytes()); + out +} + +/// `{1,2,3}` int4 array: ndim, dataoffset, elemtype, dim, lbound, elements +fn array_int4_1_2_3() -> Vec { + let mut out = Vec::new(); + for v in [1i32, 0, 23, 3, 1] { + out.extend_from_slice(&v.to_le_bytes()); + } + for v in [1i32, 2, 3] { + out.extend_from_slice(&v.to_le_bytes()); + } + out +} + +#[tokio::test(flavor = "current_thread")] +async fn bridge_hello_and_decode_batch() { + if !pg_available() { + eprintln!("skip: no initdb on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let guard = start_pg(&tmp, BASE_PORT); + let bridge = dial(&guard.sh).await; + + let info = bridge.info().expect("hello"); + assert_eq!(info.proto, PROTO_VERSION); + assert_eq!(info.projection, PROJECTION_VERSION); + assert!(info.pg_version_num >= 160000, "{info:?}"); + // Plain cluster, so no recovery. Shape of the field, not its value + assert!(!info.in_recovery); + // Shared memory read, zero outside recovery + assert_eq!(bridge.replay_lsn().await.expect("replay_lsn"), 0); + + let text = body(b"hi there"); + let bpchar = body(b"pad "); + let numeric = numeric_42(); + let array = array_int4_1_2_3(); + let items: Vec<(u32, &[u8])> = vec![ + (23, &[42, 0, 0, 0]), // int4 + (16, &[1]), // bool + (25, &text), // text + (1042, &bpchar), // bpchar + (1700, &numeric), // numeric + (1007, &array), // int4[] + (23, &[0x00]), // int4 body shorter than typlen + (999_999, &[0x00]), // no such type + (23, &[7, 0, 0, 0]), // batch keeps going after the errors + ]; + let out = bridge.decode(&items).await.expect("decode"); + + assert_eq!(out.len(), items.len()); + let want = ["42", "t", "hi there", "pad ", "42", "{1,2,3}"]; + for (got, want) in out.iter().zip(want) { + assert_eq!(got, &DecodedItem::Text(want.to_string())); + } + assert!(matches!(out[6], DecodedItem::Error(_)), "{:?}", out[6]); + assert!(matches!(out[7], DecodedItem::Error(_)), "{:?}", out[7]); + assert_eq!(out[8], DecodedItem::Text("7".to_string())); + + use std::sync::atomic::Ordering; + assert_eq!(bridge.stats.decode_item_errors.load(Ordering::Relaxed), 2); + assert!(bridge.is_up()); +} + +/// Every branch of `ws_decode_datum_text`, differentially against the same +/// cluster's `typoutput`. Was `pgext`'s pg_regress suite before the SQL +/// surface went away; the socket is the only entry point now. +#[tokio::test(flavor = "current_thread")] +async fn bridge_decode_matches_typoutput() { + if !pg_available() { + eprintln!("skip: no initdb on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let guard = start_pg(&tmp, BASE_PORT + 5); + let bridge = dial(&guard.sh).await; + let sql = connect_sql(&guard.sh).await; + + let long_text = "a".repeat(1024); + let uuid = (0u8..16).map(|i| i * 17).collect::>(); + // (type name, on-disk body, SQL literal PG renders for comparison) + let cases: Vec<(&str, Vec, String)> = vec![ + // varlena: body reused as the datum, no header rebuild + ("text", b"hello".to_vec(), "'hello'".into()), + ( + "text", + "héllo wörld".as_bytes().to_vec(), + "'héllo wörld'".into(), + ), + ("text", Vec::new(), "''".into()), + ("varchar", b"abc".to_vec(), "'abc'".into()), + ( + "bytea", + vec![0xde, 0xad, 0xbe, 0xef], + "'\\xdeadbeef'".into(), + ), + ("json", br#"{"k":1}"#.to_vec(), r#"'{"k":1}'"#.into()), + // >126 bytes, so PG stores a 4-byte header on disk + ( + "text", + long_text.clone().into_bytes(), + format!("'{long_text}'"), + ), + // fixed pass-by-value, little endian on disk + ("int2", 42i16.to_le_bytes().to_vec(), "42".into()), + ("int4", 42i32.to_le_bytes().to_vec(), "42".into()), + ("int4", (-1i32).to_le_bytes().to_vec(), "-1".into()), + ( + "int8", + 1_234_567_890i64.to_le_bytes().to_vec(), + "1234567890".into(), + ), + ("bool", vec![1], "true".into()), + ("bool", vec![0], "false".into()), + ("oid", 1234u32.to_le_bytes().to_vec(), "1234".into()), + ("float4", 1.0f32.to_le_bytes().to_vec(), "1.0".into()), + ("float8", 1.0f64.to_le_bytes().to_vec(), "1.0".into()), + // Trailing bytes past typlen are ignored, not an error + ( + "int4", + vec![42, 0, 0, 0, 0xff, 0xff, 0xff, 0xff], + "42".into(), + ), + // fixed pass-by-reference + ( + "uuid", + uuid.clone(), + "'00112233-4455-6677-8899-aabbccddeeff'".into(), + ), + ]; + + let mut items: Vec<(u32, &[u8])> = Vec::with_capacity(cases.len()); + for (ty, raw, _) in &cases { + items.push((oid_of_type(&sql, ty).await, raw.as_slice())); + } + let out = bridge.decode(&items).await.expect("decode"); + assert_eq!(out.len(), cases.len()); + + for (got, (ty, _, literal)) in out.iter().zip(&cases) { + // `format('%s')` runs the type's output function; `::text` would pick + // up a pg_cast entry instead (bool renders "true", boolout gives "t") + let want = scalar(&sql, &format!("SELECT format('%s', ({literal})::{ty})")).await; + assert_eq!( + got, + &DecodedItem::Text(want.clone()), + "{ty} from {literal} — PG renders {want}", + ); + } + + // Fixed-width bodies shorter than typlen, and a type oid with no row, + // are per-item errors + let empty: &[u8] = &[]; + let short_uuid: &[u8] = &[0x00, 0x11]; + let bad: Vec<(u32, &[u8])> = vec![ + (oid_of_type(&sql, "int4").await, empty), + (oid_of_type(&sql, "uuid").await, short_uuid), + (2_147_483_647, &[0x00]), + ]; + let out = bridge.decode(&bad).await.expect("decode"); + for (i, item) in out.iter().enumerate() { + assert!(matches!(item, DecodedItem::Error(_)), "{i}: {item:?}"); + } + assert!(bridge.is_up()); +} + +#[tokio::test(flavor = "current_thread")] +async fn bridge_scans_uncommitted_ddl() { + if !pg_available() { + eprintln!("skip: no initdb on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let guard = start_pg(&tmp, BASE_PORT + 1); + let bridge = dial(&guard.sh).await; + + let setup = connect_sql(&guard.sh).await; + setup + .batch_execute( + "CREATE TABLE t (id int PRIMARY KEY, a text); + CREATE TABLE v (id int);", + ) + .await + .expect("committed setup"); + let oid_t = oid_of(&setup, "t").await; + let oid_v = oid_of(&setup, "v").await; + + // Transaction under test, left open across every scan below + let ddl = connect_sql(&guard.sh).await; + ddl.batch_execute( + "BEGIN; + ALTER TABLE t ADD COLUMN c int DEFAULT 7; + CREATE TABLE u (x int); + CREATE SCHEMA mine;", + ) + .await + .expect("open ddl"); + let xid = top_xid(&ddl).await; + let oid_u = oid_of(&ddl, "u").await; + + // Top xid 0 owns nothing, so the same scan answers the committed view and + // the open transaction's work is foreign to it + let committed = bridge + .scan(Catalog::Attribute, 0, &[oid_t]) + .await + .expect("committed pg_attribute") + .parse::() + .expect("attribute rows"); + let cols: Vec<&str> = committed.iter().map(|a| a.attname.as_str()).collect(); + assert_eq!(cols, ["id", "a"], "the added column is not committed"); + // No oid list is the whole catalog, on every catalog and not just the two + // that never had a list + let all = bridge + .scan(Catalog::Class, 0, &[]) + .await + .expect("whole pg_class") + .parse::() + .expect("class rows"); + assert!( + all.iter().any(|r| r.relname == "t") && all.iter().any(|r| r.relname == "v"), + "{} rows and neither committed table in them", + all.len(), + ); + assert!( + !all.iter().any(|r| r.oid == oid_u), + "a relation created in-transaction is not committed", + ); + // attnum >= 1 is the projection's shape, not something the oid list + // happens to buy: system columns would shift every descriptor slot + let every_attr = bridge + .scan(Catalog::Attribute, 0, &[]) + .await + .expect("whole pg_attribute") + .parse::() + .expect("attribute rows"); + assert!( + every_attr.iter().all(|a| a.attnum >= 1), + "system columns in a whole-catalog scan", + ); + assert!( + every_attr + .iter() + .any(|a| a.attrelid == oid_t && a.attname == "a"), + "{} rows and none of them t.a", + every_attr.len(), + ); + + // A relation created inside the open transaction resolves by oid, and the + // altered one yields one row despite its superseded versions on the page + let class = bridge + .scan(Catalog::Class, xid, &[oid_t, oid_u]) + .await + .expect("scan pg_class") + .parse::() + .expect("class rows"); + let mut names: Vec<&str> = class.iter().map(|r| r.relname.as_str()).collect(); + names.sort_unstable(); + assert_eq!(names, ["t", "u"], "{class:#?}"); + assert!(class.iter().all(|r| r.relkind == 'r')); + + let attrs = bridge + .scan(Catalog::Attribute, xid, &[oid_t]) + .await + .expect("scan pg_attribute"); + // System columns sit at negative attnums and the projection filters them + assert!(attrs.rows.iter().len() >= 3); + let attrs = attrs.parse::().expect("attribute rows"); + let cols: Vec<&str> = attrs.iter().map(|a| a.attname.as_str()).collect(); + assert_eq!(cols, ["id", "a", "c"], "{attrs:#?}"); + assert!(attrs.iter().all(|a| a.attnum >= 1)); + let added = attrs.iter().find(|a| a.attname == "c").unwrap(); + assert_eq!(added.attmissingval.as_deref(), Some("{7}")); + assert_eq!(added.atttypid, 23); + assert!(attrs[0].attmissingval.is_none()); + + let indexes = bridge + .scan(Catalog::Index, xid, &[oid_t]) + .await + .expect("scan pg_index") + .parse::() + .expect("index rows"); + let pkey = indexes.iter().find(|i| i.indisprimary).expect("pkey row"); + assert_eq!(pkey.indkey, vec![1]); + assert_eq!(pkey.indrelid, oid_t); + + // Whole-catalog scans have no lock argument, and a foreign in-progress + // writer has no recorded parent, indistinguishable from an unassigned + // subtransaction of ours: the scan refuses to guess + let other = connect_sql(&guard.sh).await; + other + .batch_execute("BEGIN; CREATE SCHEMA theirs;") + .await + .expect("foreign ddl"); + let err = bridge.scan(Catalog::Namespace, xid, &[]).await.unwrap_err(); + assert!( + matches!(err, BridgeError::Remote(ref m) if m.contains("inconclusive")), + "{err:?}" + ); + other.batch_execute("ROLLBACK").await.expect("foreign undo"); + // Aborted, the writer resolves and the scan answers: ours present, + // the aborted foreign insert skipped + let namespaces = bridge + .scan(Catalog::Namespace, xid, &[]) + .await + .expect("scan pg_namespace") + .parse::() + .expect("namespace rows"); + let names: Vec<&str> = namespaces.iter().map(|n| n.nspname.as_str()).collect(); + assert!(names.contains(&"mine"), "{names:?}"); + assert!(!names.contains(&"theirs"), "{names:?}"); + + // A reverted savepoint leaves its tuple aborted, and restores the version + // it superseded, so the column count is unchanged and parentage resolved + ddl.batch_execute( + "SAVEPOINT s; + ALTER TABLE t ADD COLUMN d int; + ROLLBACK TO SAVEPOINT s;", + ) + .await + .expect("savepoint"); + let after = bridge + .scan(Catalog::Attribute, xid, &[oid_t]) + .await + .expect("scan after savepoint"); + assert_eq!(after.subtrans_mismatch, 0, "{after:#?}"); + let cols: Vec = after + .parse::() + .expect("attribute rows") + .into_iter() + .map(|a| a.attname) + .collect(); + assert_eq!(cols, ["id", "a", "c"]); + let class = bridge + .scan(Catalog::Class, xid, &[oid_t]) + .await + .expect("scan pg_class after savepoint"); + assert_eq!(class.rows.len(), 1, "{class:#?}"); + + // Nothing the foreign transaction touched leaked into the answer + let untouched = bridge + .scan(Catalog::Attribute, xid, &[oid_v]) + .await + .expect("scan v") + .parse::() + .expect("attribute rows"); + let cols: Vec<&str> = untouched.iter().map(|a| a.attname.as_str()).collect(); + assert_eq!(cols, ["id"]); + + ddl.batch_execute("ROLLBACK").await.expect("undo"); + let committed = bridge + .scan(Catalog::Attribute, xid, &[oid_t]) + .await + .expect("scan after rollback") + .parse::() + .expect("attribute rows"); + let cols: Vec<&str> = committed.iter().map(|a| a.attname.as_str()).collect(); + assert_eq!(cols, ["id", "a"], "aborted tree still visible"); + + // Every handled error above was an answered frame, never a dropped + // connection: one worker connection served the whole test + use std::sync::atomic::Ordering; + assert_eq!(bridge.stats.reconnects.load(Ordering::Relaxed), 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn bridge_reconnects_after_worker_exit() { + if !pg_available() { + eprintln!("skip: no initdb on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let guard = start_pg(&tmp, BASE_PORT + 2); + let bridge = dial(&guard.sh).await; + assert_eq!(bridge.replay_lsn().await.expect("before"), 0); + + let sql = connect_sql(&guard.sh).await; + // Contrary database defaults: the restarted worker's fresh connection + // inherits these unless it pins its decode output environment + sql.batch_execute( + "ALTER DATABASE postgres SET timezone TO 'America/New_York'; + ALTER DATABASE postgres SET datestyle TO 'German, DMY'; + ALTER DATABASE postgres SET intervalstyle TO 'sql_standard'; + ALTER DATABASE postgres SET bytea_output TO 'escape';", + ) + .await + .expect("contrary defaults"); + + let killed = scalar( + &sql, + "SELECT count(pg_terminate_backend(pid))::text \ + FROM pg_stat_activity WHERE backend_type = 'walshadow bridge'", + ) + .await; + assert_eq!(killed, "1", "bridge worker not running"); + + // bgw_restart_time is 5s, so poll rather than assume the next call lands. + // pg_terminate_backend returns at signal delivery, so a call can still + // reach the old worker; only an answer after a reconnect is the new one + use std::sync::atomic::Ordering; + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let res = bridge.replay_lsn().await; + let reconnected = bridge.stats.reconnects.load(Ordering::Relaxed) >= 1; + match res { + Ok(_) if reconnected => break, + Ok(_) | Err(_) if Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(250)).await; + } + Ok(_) => panic!("worker survived pg_terminate_backend"), + Err(e) => panic!("bridge never came back: {e}"), + } + } + assert!(bridge.is_up()); + // Postmaster restarted the worker, not the cluster + assert_eq!(scalar(&sql, "SELECT 'alive'").await, "alive"); + + // The defaults did land on fresh connections... + let fresh = connect_sql(&guard.sh).await; + assert_eq!(scalar(&fresh, "SHOW timezone").await, "America/New_York"); + // ...and the worker pinned its canonical output over them + let interval_90s = { + let mut b = 90_000_000i64.to_le_bytes().to_vec(); // µs + b.extend_from_slice(&0i32.to_le_bytes()); // days + b.extend_from_slice(&0i32.to_le_bytes()); // months + b + }; + let items: Vec<(u32, &[u8])> = vec![ + (1184, &[0; 8]), // timestamptz, µs since 2000-01-01 UTC + (1082, &[0; 4]), // date, days since 2000-01-01 + (1186, &interval_90s), // interval + (17, &[0xde, 0xad]), // bytea + ]; + let out = bridge.decode(&items).await.expect("decode"); + let want = [ + "2000-01-01 00:00:00+00", + "2000-01-01", + "00:01:30", + r"\xdead", + ]; + for (got, want) in out.iter().zip(want) { + assert_eq!(got, &DecodedItem::Text(want.to_string())); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn bridge_drops_bad_frames_per_connection() { + if !pg_available() { + eprintln!("skip: no initdb on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let guard = start_pg(&tmp, BASE_PORT + 3); + let bridge = dial(&guard.sh).await; + let path = guard.sh.bridge_socket().unwrap().to_path_buf(); + + // Over walshadow.max_request_mb, and zero-length: both close the + // connection before any payload is read + for header in [u32::MAX, 0] { + let mut raw = UnixStream::connect(&path).expect("raw connect"); + raw.write_all(&header.to_be_bytes()).expect("write header"); + let mut buf = [0u8; 1]; + assert_eq!( + raw.read(&mut buf).expect("read after bad frame"), + 0, + "frame header {header} did not close the connection" + ); + } + // Abrupt close mid-frame + { + let mut raw = UnixStream::connect(&path).expect("raw connect"); + raw.write_all(&64u32.to_be_bytes()).expect("write header"); + raw.write_all(&[0x02]).expect("write op"); + } + + // The healthy connection never noticed + assert_eq!(bridge.replay_lsn().await.expect("still serving"), 0); + use std::sync::atomic::Ordering; + assert_eq!(bridge.stats.reconnects.load(Ordering::Relaxed), 0); +} + +/// Three row sources — the mirroring statement, `SCAN` at top xid 0, and +/// `SCAN` under a transaction that wrote nothing — must build the same +/// `RelDescriptor`; overlay then tracks open transaction, and the statement +/// stays on the committed shape. Boundary is 0 throughout because this cluster +/// is not a standby. +#[tokio::test(flavor = "current_thread")] +async fn bridge_overlay_descriptors_track_open_ddl() { + if !pg_available() { + eprintln!("skip: no initdb on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let guard = start_pg(&tmp, BASE_PORT + 6); + let bridge = Arc::new(dial(&guard.sh).await); + let mut cat = open_catalog(&guard.sh, bridge).await; + let (_stand_in, mut mirror) = + open_mirror_catalog(&guard.sh, &tmp.path().join("moving.sock")).await; + + let setup = connect_sql(&guard.sh).await; + setup + .batch_execute( + "CREATE SCHEMA app; + CREATE TABLE app.t (id int PRIMARY KEY, a text); + -- committed attmissingval and a dropped slot, which both sources + -- must render the same way + CREATE TABLE app.m (id int, gone text); + ALTER TABLE app.m ADD COLUMN v numeric[] DEFAULT '{1.5}'; + ALTER TABLE app.m DROP COLUMN gone;", + ) + .await + .expect("committed setup"); + let oid_t = oid_of(&setup, "app.t").await; + + let (_, committed) = cat + .fetch_descriptors_batch(&[oid_t]) + .await + .expect("committed batch"); + let (_, stated) = mirror + .fetch_descriptors_batch(&[oid_t]) + .await + .expect("mirroring statement"); + assert_eq!(stated, committed, "statement diverged from the worker"); + assert_eq!(mirror.stats().mirror_fetches, 1); + // Capture-all: no oid list on the wire, the eligibility predicate in SQL + let by_oid = |(_, mut descs): (u64, Vec<_>)| { + descs.sort_by_key(|d: &walshadow::schema::RelDescriptor| d.oid); + descs + }; + assert_eq!( + mirror.fetch_all_descriptors().await.map(by_oid).unwrap(), + cat.fetch_all_descriptors().await.map(by_oid).unwrap(), + "statement and worker disagree on the eligible set", + ); + + // An xid that wrote nothing sees only committed rows + let idle = connect_sql(&guard.sh).await; + idle.batch_execute("BEGIN").await.expect("begin idle"); + let idle_xid = top_xid(&idle).await; + let overlay = cat + .fetch_overlay_descriptors(&[oid_t], idle_xid, 0) + .await + .expect("overlay batch"); + assert_eq!( + overlay, committed, + "overlay diverged from the committed read" + ); + idle.batch_execute("ROLLBACK").await.expect("rollback idle"); + + // Transaction under test. Its schema and domain are invisible to the + // committed name reads, so both fall through to a whole-catalog overlay + let ddl = connect_sql(&guard.sh).await; + ddl.batch_execute( + "BEGIN; + ALTER TABLE app.t ADD COLUMN c int DEFAULT 7; + CREATE SCHEMA fresh; + CREATE DOMAIN fresh.cents AS int; + CREATE TABLE fresh.u (id int PRIMARY KEY, amount fresh.cents);", + ) + .await + .expect("open ddl"); + let xid = top_xid(&ddl).await; + let oid_u = oid_of(&ddl, "fresh.u").await; + + let mut descs = cat + .fetch_overlay_descriptors(&[oid_t, oid_u], xid, 0) + .await + .expect("overlay under open ddl"); + descs.sort_by_key(|d| d.oid); + let (t, u) = match descs.as_slice() { + [t, u] if t.oid == oid_t => (t, u), + other => panic!("{other:#?}"), + }; + + let cols: Vec<&str> = t.attributes.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(cols, ["id", "a", "c"]); + assert_eq!(t.attributes[2].missing_text.as_deref(), Some("7")); + assert_eq!(t.attributes[2].type_name, "int4"); + // Unchanged by the ALTER, so still the committed values + assert_eq!(t.rfn, committed[0].rfn); + assert_eq!(t.replident, committed[0].replident); + assert_eq!(t.rel_name.to_string(), "app.t"); + + assert_eq!(u.rel_name.to_string(), "fresh.u", "in-xact schema name"); + assert_eq!( + u.attributes[1].type_name, "cents", + "in-xact domain name: {:#?}", + u.attributes + ); + assert_eq!( + u.replident, + ReplIdent::Default { + pk_attnums: Some(vec![1]), + } + ); + assert_ne!(u.rfn.rel_node, 0, "created in-xact but its storage exists"); + assert_eq!(u.rfn.spc_node, committed[0].rfn.spc_node); + assert_eq!(u.rfn.db_node, committed[0].rfn.db_node); + + // An MVCC snapshot reaches none of it: the added column, the new relation, + // and the schema and type the same transaction created + let (_, stated) = mirror + .fetch_descriptors_batch(&[oid_t, oid_u]) + .await + .expect("statement under open ddl"); + assert_eq!(stated, committed, "statement saw uncommitted rows"); + + // Replay off the asserted boundary is the caller's whole basis for reading + // uncommitted rows, so it fails rather than answering + let err = cat + .fetch_overlay_descriptors(&[oid_t], xid, 0x1000) + .await + .unwrap_err(); + assert!( + matches!( + &err, + CatalogError::Bridge(BridgeError::ReplayMismatch { .. }) + ), + "{err}" + ); + + ddl.batch_execute("ROLLBACK").await.expect("undo"); + let after = cat + .fetch_overlay_descriptors(&[oid_t, oid_u], xid, 0) + .await + .expect("overlay after rollback"); + assert_eq!(after, committed, "aborted tree still visible"); +} + +fn read_frame(sock: &mut UnixStream) -> Vec { + let mut hdr = [0u8; 4]; + sock.read_exact(&mut hdr).expect("frame header"); + let mut body = vec![0u8; u32::from_be_bytes(hdr) as usize]; + sock.read_exact(&mut body).expect("frame body"); + body +} + +/// Error status, `u32` length, message, nothing else: the whole frame +fn parse_error_frame(body: &[u8]) -> String { + assert_eq!(body[0], 1, "status byte: {body:?}"); + let mlen = u32::from_be_bytes(body[1..5].try_into().unwrap()) as usize; + assert_eq!(body.len(), 5 + mlen, "frame not exactly consumed: {body:?}"); + String::from_utf8(body[5..].to_vec()).expect("utf8 message") +} + +#[tokio::test(flavor = "current_thread")] +async fn bridge_error_frames_stay_parseable() { + if !pg_available() { + eprintln!("skip: no initdb on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let guard = start_pg(&tmp, BASE_PORT + 4); + let bridge = dial(&guard.sh).await; + let path = guard.sh.bridge_socket().unwrap().to_path_buf(); + let mut raw = UnixStream::connect(&path).expect("raw connect"); + + // Unknown opcode: one well-formed error frame, not a status byte + // overwritten by the frame-length backfill + raw.write_all(&1u32.to_be_bytes()).expect("write header"); + raw.write_all(&[0xee]).expect("write op"); + let msg = parse_error_frame(&read_frame(&mut raw)); + assert!(msg.contains("opcode"), "{msg}"); + + // Trailing request bytes mean the peer framed a different request + let mut hello = 1u32.to_be_bytes().to_vec(); + hello.push(0x01); + raw.write_all(&hello).expect("write hello"); + let body = read_frame(&mut raw); + assert_eq!(body[0], 0, "clean hello answers ok: {body:?}"); + let mut oversized = hello.clone(); + oversized[3] += 2; // frame length now counts the junk + oversized.extend_from_slice(&[0xba, 0xad]); + raw.write_all(&oversized).expect("write hello with junk"); + parse_error_frame(&read_frame(&mut raw)); + + // Same connection serves the next request + raw.write_all(&1u32.to_be_bytes()).expect("write header"); + raw.write_all(&[0x04]).expect("write replay_lsn"); + let body = read_frame(&mut raw); + assert_eq!(body[0], 0, "{body:?}"); + assert_eq!(body.len(), 9); + + assert_eq!(bridge.replay_lsn().await.expect("still serving"), 0); +} + +/// Worker stand-in that answers `HELLO` honestly and then reports a replay +/// position that moved inside the scan. Real movement wants a live standby +/// mid-stream; the daemon-side branch is the same either way. +fn spawn_moving_worker(listener: tokio::net::UnixListener) { + tokio::spawn(async move { + while let Ok((mut sock, _)) = listener.accept().await { + loop { + let mut hdr = [0u8; 4]; + if tokio::io::AsyncReadExt::read_exact(&mut sock, &mut hdr) + .await + .is_err() + { + break; + } + let mut req = vec![0u8; u32::from_be_bytes(hdr) as usize]; + if tokio::io::AsyncReadExt::read_exact(&mut sock, &mut req) + .await + .is_err() + { + break; + } + let mut body = vec![0u8]; + if req.first() == Some(&0x01) { + body.extend_from_slice(&PROTO_VERSION.to_be_bytes()); + body.extend_from_slice(&PROJECTION_VERSION.to_be_bytes()); + body.extend_from_slice(&170_000u32.to_be_bytes()); + body.push(1); + } else { + // No rows, and the two positions disagree + body.extend_from_slice(&0x1000u64.to_be_bytes()); + body.extend_from_slice(&0x2000u64.to_be_bytes()); + for _ in 0..3 { + body.extend_from_slice(&0u32.to_be_bytes()); + } + body.extend_from_slice(&(Catalog::Class.ncols() as u16).to_be_bytes()); + } + let mut frame = (body.len() as u32).to_be_bytes().to_vec(); + frame.extend_from_slice(&body); + if tokio::io::AsyncWriteExt::write_all(&mut sock, &frame) + .await + .is_err() + { + break; + } + } + } + }); +} + +/// 8. Replay movement sends a committed read to the mirroring statement and +/// fails an overlay read outright. +#[tokio::test(flavor = "current_thread")] +async fn bridge_committed_read_falls_back_when_replay_moves() { + use std::sync::atomic::Ordering; + + if !pg_available() { + eprintln!("skip: no initdb on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let guard = start_pg(&tmp, BASE_PORT + 7); + let setup = connect_sql(&guard.sh).await; + setup + .batch_execute("CREATE TABLE t (id int PRIMARY KEY, a text)") + .await + .expect("setup"); + let oid_t = oid_of(&setup, "public.t").await; + + let (bridge, mut cat) = open_mirror_catalog(&guard.sh, &tmp.path().join("moving.sock")).await; + + // No sequence of scans answers for one position once replay moves, and + // the statement's one snapshot always can + let (_, descs) = cat + .fetch_descriptors_batch(&[oid_t]) + .await + .expect("committed read after the pin broke"); + assert_eq!(bridge.stats.scan_replay_moved.load(Ordering::Relaxed), 1); + assert_eq!(cat.stats().mirror_fetches, 1); + let (_, scanned) = open_catalog(&guard.sh, Arc::new(dial(&guard.sh).await)) + .await + .fetch_descriptors_batch(&[oid_t]) + .await + .expect("worker at a position that holds still"); + assert_eq!(descs, scanned, "fallback built a different descriptor"); + + // The caller holds the boundary an overlay read is about, so nothing else + // can serve that question + let err = cat + .fetch_overlay_descriptors(&[oid_t], 700, 0x1000) + .await + .unwrap_err(); + assert!( + matches!( + err, + CatalogError::Bridge(BridgeError::ReplayMismatch { .. }) + ), + "{err:?}" + ); +} diff --git a/tests/common/bootstrap_ch_fixture.rs b/tests/common/bootstrap_ch_fixture.rs index 4e1e2b6..f95b957 100644 --- a/tests/common/bootstrap_ch_fixture.rs +++ b/tests/common/bootstrap_ch_fixture.rs @@ -19,14 +19,14 @@ use std::fs; use std::io::Write; use std::net::TcpStream; use std::os::unix::process::CommandExt; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use walshadow::mapping::TableTarget; use walshadow::schema::RelName; -use walshadow::shadow::Shadow; +use walshadow::shadow::{BridgeConf, Shadow}; /// ClickHouse server subprocess wrapper shared by the pipeline DDL /// drill, both bootstrap-to-CH drills, and the @@ -179,6 +179,40 @@ pub fn pg_basebackup_available() -> bool { .unwrap_or(false) } +/// Build tree holding `walshadow.so`, fed to PG as `dynamic_library_path`. +/// Daemon dials the bridge worker at boot, so an unbuilt module is a failure, +/// not a reason to skip +pub fn pgext_dir() -> PathBuf { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("pgext"); + assert!( + dir.join("walshadow.so").is_file(), + "pgext/walshadow.so missing, run `make -C pgext`" + ); + dir +} + +/// Preload the bridge worker on a test-owned shadow, listening where the +/// daemon dials by default (`/walshadow-bridge.sock`). +/// Appends, so it stacks on the retargeting each drill already did. +/// Daemon-owned shadows need `--bridge-lib-dir` instead, the daemon writes +/// its own conf +pub fn append_bridge_conf( + data_dir: &Path, + socket_dir: &Path, + dbname: &str, + lib_dir: PathBuf, +) -> Result<()> { + let mut bridge = BridgeConf::in_dir(socket_dir); + bridge.library_dir = Some(lib_dir); + let conf = data_dir.join("postgresql.conf"); + fs::OpenOptions::new() + .append(true) + .open(&conf) + .with_context(|| format!("open {}", conf.display()))? + .write_all(bridge.conf_text(dbname).as_bytes())?; + Ok(()) +} + /// Write a `--ch-config` TOML mapping `.t (id int4, name text)` /// onto `.t` on CH. Mirrors the synthetic column shape the /// emitter advertises (`_lsn` / `_xid` / `_commit_ts` / `_is_deleted`). diff --git a/tests/common/inproc_harness.rs b/tests/common/inproc_harness.rs index 92dbd9f..722aa3f 100644 --- a/tests/common/inproc_harness.rs +++ b/tests/common/inproc_harness.rs @@ -334,6 +334,18 @@ pub struct BootstrappedClusters { pub shadow_filter_dir: PathBuf, } +/// Build tree holding `walshadow.so`, fed to shadow as `dynamic_library_path`. +/// Shadow preloads the bridge worker, so an unbuilt module is a failure, not a +/// reason to skip +pub fn pgext_dir() -> PathBuf { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("pgext"); + assert!( + dir.join("walshadow.so").is_file(), + "pgext/walshadow.so missing, run `make -C pgext`" + ); + dir +} + pub async fn bootstrap_clusters( tmp: &tempfile::TempDir, schema_sql: &str, @@ -343,6 +355,51 @@ pub async fn bootstrap_clusters( ) -> ( BootstrappedClusters, Arc>, +) { + bootstrap_clusters_inner( + tmp, + schema_sql, + source_port, + shadow_port, + walsender_port, + true, + ) + .await +} + +/// Same, plus the preloaded bridge worker on shadow. Reach its socket through +/// [`Shadow::bridge_socket`]. +pub async fn bootstrap_clusters_with_bridge( + tmp: &tempfile::TempDir, + schema_sql: &str, + source_port: u16, + shadow_port: u16, + walsender_port: u16, +) -> ( + BootstrappedClusters, + Arc>, +) { + bootstrap_clusters_inner( + tmp, + schema_sql, + source_port, + shadow_port, + walsender_port, + true, + ) + .await +} + +async fn bootstrap_clusters_inner( + tmp: &tempfile::TempDir, + schema_sql: &str, + source_port: u16, + shadow_port: u16, + walsender_port: u16, + with_bridge: bool, +) -> ( + BootstrappedClusters, + Arc>, ) { let source = make_pg(tmp, "source", source_port); source.initdb().expect("initdb source"); @@ -391,8 +448,21 @@ pub async fn bootstrap_clusters( let mut shadow_cfg = ShadowConfig::new(shadow_data.clone(), shadow_filter_dir.clone()); shadow_cfg.port = shadow_port; - shadow_cfg.socket_dir = shadow_sock; + shadow_cfg.socket_dir = shadow_sock.clone(); shadow_cfg.ctl_timeout = Duration::from_secs(60); + if with_bridge { + let mut bridge = walshadow::shadow::BridgeConf::in_dir(&shadow_sock); + bridge.library_dir = Some(pgext_dir()); + // basebackup-cloned conf, so append rather than materialize + let conf = shadow_data.join("postgresql.conf"); + fs::OpenOptions::new() + .append(true) + .open(&conf) + .expect("open shadow conf") + .write_all(bridge.conf_text(&shadow_cfg.dbname).as_bytes()) + .expect("append bridge conf"); + shadow_cfg.bridge = Some(bridge); + } let shadow = Shadow::new(shadow_cfg); if let Err(e) = shadow.start() { let log = fs::read_to_string(shadow_data.join("startup.log")) @@ -499,13 +569,19 @@ impl RecordSink for PipelineSinks { Box> + Send + 'a>, > { Box::pin(async move { - if let (Some(capture), Some(info)) = (&self.capture, &record.boundary_info) { + if let (Some(capture), Some(members)) = (&self.capture, &record.aborted_tree) { + capture.forget_aborted(members); + } + if let (Some(capture), Some(info)) = (&self.capture, &record.boundary_info) + && capture.admits(info, record.next_lsn) + { self.catalog .lock() .await .wait_for_replay(record.next_lsn) .await .map_err(|e| SinkError::Other(format!("harness boundary wait: {e}")))?; + capture.charge_hold(info, std::time::Duration::ZERO); capture .capture_boundary(info, record.source_lsn, record.next_lsn) .await?; @@ -635,7 +711,13 @@ async fn build_pipeline_inner( replay_poll: Duration::from_millis(50), ..Default::default() }; - let catalog = ShadowCatalog::connect(&shadow_conninfo, cat_cfg) + let bridge_path = shadow.bridge_socket().expect("bridge configured"); + let bridge = Arc::new( + walshadow::bridge::connect_with_budget(bridge_path, Duration::from_secs(60)) + .await + .unwrap_or_else(|e| panic!("bridge connect on {}: {e}", bridge_path.display())), + ); + let catalog = ShadowCatalog::connect(&shadow_conninfo, cat_cfg, bridge) .await .expect("connect shadow catalog"); let catalog = Arc::new(Mutex::new(catalog)); @@ -737,6 +819,8 @@ async fn build_pipeline_inner( } tune(&mut emitter_cfg); + let pending_cfg = emitter_cfg.pending_capture; + let pending_catalog = Arc::new(walshadow::pending::PendingCatalog::default()); // SIGHUP-reloadable mapping shared by the DDL applicator + decode pool. let mapping = walshadow::mapping::mapping_handle(emitter_cfg.tables.clone()); @@ -807,6 +891,7 @@ async fn build_pipeline_inner( buffer: xact_buffer.clone(), subxact_tracker: Arc::new(Mutex::new(SubxactTracker::new())), log: desc_log.clone(), + pending: pending_catalog.clone(), stats: stats.clone(), span_registry: None, config_resolver: Some(config_resolver.clone()), @@ -839,6 +924,8 @@ async fn build_pipeline_inner( catalog.clone(), xact_buffer.clone(), smgr_markers, + pending_catalog.clone(), + pending_cfg, ); let sinks = PipelineSinks { metrics: MetricsRecordSink::default(), diff --git a/tests/control_plane_e2e.rs b/tests/control_plane_e2e.rs index 76c544f..f9141e4 100644 --- a/tests/control_plane_e2e.rs +++ b/tests/control_plane_e2e.rs @@ -168,6 +168,9 @@ impl Harness { // Long-lived daemon: no --max-segments, so run_session streams // forever and the tests drive it live. let bin = env!("CARGO_BIN_EXE_walshadow-stream").to_string(); + // Shadow is daemon-owned, so the daemon writes the preload line itself + // and only needs to be told where the un-installed module sits + let pgext_dir = fx::pgext_dir(); let stderr_path = tmp.path().join("daemon.stderr.log"); let stderr_file = fs::File::create(&stderr_path).context("open daemon stderr")?; let metrics_addr: SocketAddr = format!("127.0.0.1:{}", ports.metrics).parse().unwrap(); @@ -213,6 +216,8 @@ impl Harness { shadow_data.to_str().unwrap(), "--bootstrap-shadow-replay-timeout", "120", + "--bridge-lib-dir", + pgext_dir.to_str().unwrap(), ]) .env("RUST_LOG", "warn,walshadow=info") .stdout(Stdio::null()) diff --git a/tests/dirty_admission_e2e.rs b/tests/dirty_admission_e2e.rs index f17eed2..aceb952 100644 --- a/tests/dirty_admission_e2e.rs +++ b/tests/dirty_admission_e2e.rs @@ -96,6 +96,16 @@ struct Drill { /// Bootstrap clusters + CH + auto-create pipeline for one namespace. async fn build_drill(slot: PortSlot, schema_sql: &str, namespace: &str, app_name: &str) -> Drill { + build_drill_with(slot, schema_sql, namespace, app_name, |_| {}).await +} + +async fn build_drill_with( + slot: PortSlot, + schema_sql: &str, + namespace: &str, + app_name: &str, + tune: impl FnOnce(&mut walshadow::ch_emitter::EmitterConfig), +) -> Drill { let tmp = tempfile::tempdir().unwrap(); let ( fx::BootstrappedClusters { @@ -121,18 +131,21 @@ async fn build_drill(slot: PortSlot, schema_sql: &str, namespace: &str, app_name }, ); - let pipeline = fx::build_pipeline(fx::BuildPipelineArgs { - tmp: &tmp, - source: &source, - shadow: &shadow, - shadow_filter_dir: &shadow_filter_dir, - shadow_stream_state, - ch_database: "walshadow_test", - ch_tcp_port: slot.ch_tcp, - mappings: vec![], - app_name, - ddl: Some(ddl_args), - }) + let pipeline = fx::build_pipeline_with( + fx::BuildPipelineArgs { + tmp: &tmp, + source: &source, + shadow: &shadow, + shadow_filter_dir: &shadow_filter_dir, + shadow_stream_state, + ch_database: "walshadow_test", + ch_tcp_port: slot.ch_tcp, + mappings: vec![], + app_name, + ddl: Some(ddl_args), + }, + tune, + ) .await; Drill { @@ -532,17 +545,25 @@ async fn benign_in_place_alter_then_dml_delivers() { /// coercible, so PG rewrites atttypid without rotating the filenode) fences /// the records stashed inside its interval. Neither decoding under the /// post-commit descriptor nor discarding is sound, so the stream stops. +/// +/// Pending capture off (`pending_max_boundaries_per_xact = 0` degrades every +/// transaction at its first command boundary), so the fence spans the whole +/// dirty interval, the conservative fallback. +/// `pending_capture_e2e::pending_timeline_decodes_the_post_ddl_row` is the +/// same shape with the timeline on, where the fence shrinks to the run before +/// the first boundary and the row lands #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn physical_in_place_alter_fences_deferred_rows() { if skip_gate() { return; } - let mut drill = build_drill( + let mut drill = build_drill_with( SLOT_FENCE, "CREATE SCHEMA daf;\n\ CREATE TABLE daf.t (id bigint PRIMARY KEY, v varchar(10));\n", "daf", "walshadow-dirty-fence", + |cfg| cfg.pending_capture.max_boundaries_per_xact = 0, ) .await; diff --git a/tests/kill_restart.rs b/tests/kill_restart.rs index 6dbf6e2..bd53c8d 100644 --- a/tests/kill_restart.rs +++ b/tests/kill_restart.rs @@ -450,6 +450,10 @@ async fn run_drill() -> Result<()> { fs::create_dir_all(&shadow_sock)?; rewrite_for_shadow(&shadow_data, SHADOW_PORT, &shadow_sock).context("retarget shadow")?; enable_recovery(&shadow_data, &shadow_filter_dir, WALSENDER_PORT).context("recovery conf")?; + // Bridge worker outlives every daemon cycle: the daemon dials it at boot, + // so each restart reconnects to the same preloaded worker + fx::append_bridge_conf(&shadow_data, &shadow_sock, "postgres", fx::pgext_dir()) + .context("preload bridge worker")?; let mut shadow_cfg = ShadowConfig::new(shadow_data.clone(), shadow_filter_dir.clone()); shadow_cfg.port = SHADOW_PORT; diff --git a/tests/multi_segment_filter.rs b/tests/multi_segment_filter.rs index 637245d..541c249 100644 --- a/tests/multi_segment_filter.rs +++ b/tests/multi_segment_filter.rs @@ -327,6 +327,7 @@ impl RecordSink for SharedCollectingSink { route: r.route, catalog_boundary: r.catalog_boundary, boundary_info: r.boundary_info.clone(), + aborted_tree: r.aborted_tree.clone(), defer_catalog_decode: r.defer_catalog_decode, }); Ok(()) diff --git a/tests/oracle.rs b/tests/oracle.rs index 3eae35b..46cd645 100644 --- a/tests/oracle.rs +++ b/tests/oracle.rs @@ -1,36 +1,39 @@ //! Differential decode oracle. //! -//! Three drills: +//! Four drills, each against a PG started with the bridge worker preloaded +//! (`dynamic_library_path` points at the `pgext` build tree, so no +//! `make install` is needed). Skipped silently when `initdb` isn't on PATH; +//! fails when `pgext` hasn't been built: //! -//! 1. `oracle_without_extension_falls_back_to_raw_bytes` — spawn a -//! plain PG without loading the `walshadow` extension, -//! confirm `Oracle::resolve_pending` returns `Ok(None)` and the -//! `fallback_raw` stat increments. Skipped silently when `initdb` -//! isn't on PATH. -//! 2. `oracle_with_extension_resolves_tier3_disk_bytes` — same setup -//! plus `CREATE EXTENSION walshadow`. For each of -//! `numeric` / `inet` / `interval` / `jsonb` / `int4[]`, synthesize -//! on-disk bytes, call `walshadow_decode_disk(oid, bytea)`, assert -//! the returned text matches PG's `typoutput`. Skipped silently -//! when the extension isn't installed (the harness probes -//! `walshadow_decode_disk` in `pg_proc` and returns a skip). +//! 1. `oracle_resolves_tier3_disk_bytes` — for each of `numeric` / `inet` / +//! `interval` / `int4[]`, synthesize on-disk bytes and assert the resolved +//! text matches PG's `typoutput`. +//! 2. `oracle_falls_back_on_undecodable_bytes` — a body `typoutput` rejects +//! leaves the column unresolved and counts `fallback_raw`, with the oracle +//! still serving afterwards. //! 3. `oracle_resolves_pg_pending_to_text` — runs the decode pool's -//! `resolve_pending_tuple` over a `PgPending` column, asserts the -//! resolved tuple carries a `Text` value matching PG's representation. +//! `resolve_pending_tuple` over a `PgPending` column, asserts the resolved +//! tuple carries a `Text` value matching PG's representation. +//! 4. `oracle_recovers_after_cluster_restart` — resolution fails while the +//! cluster is down (counted `errors`), recovers once it is back. use std::fs; +use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; +use std::sync::atomic::Ordering; use std::time::Duration; use walrus::pg::walparser::RelFileNode; use walshadow::codecs; use walshadow::heap_decoder::{ColumnValue, CommittedTuple, DecodedHeap, DecodedTuple, HeapOp}; use walshadow::oracle::{Oracle, resolve_pending_tuple}; -use walshadow::pg::socket_conninfo; -use walshadow::shadow::{Shadow, ShadowConfig}; +use walshadow::schema::{INETOID, INTERVALOID, NUMERICOID}; +use walshadow::shadow::{BridgeConf, Shadow, ShadowConfig}; const SHADOW_PORT: u16 = 56301; +/// int4 array, ie `INT4ARRAYOID` +const INT4ARRAYOID: u32 = 1007; fn pg_available() -> bool { Command::new("initdb") @@ -42,24 +45,56 @@ fn pg_available() -> bool { .unwrap_or(false) } -fn make_pg(tmp: &tempfile::TempDir, port: u16) -> Shadow { +/// Build tree holding `walshadow.so`, fed to PG as `dynamic_library_path`. +/// Module is not optional, so an unbuilt tree fails rather than skips +fn pgext_dir() -> PathBuf { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("pgext"); + assert!( + dir.join("walshadow.so").is_file(), + "pgext/walshadow.so missing, run `make -C pgext`" + ); + dir +} + +struct StopOnDrop { + sh: Shadow, +} + +impl Drop for StopOnDrop { + fn drop(&mut self) { + let _ = self.sh.stop(); + } +} + +/// `None` skips the caller: no PG +fn start_pg(tmp: &tempfile::TempDir, port: u16) -> Option { + if !pg_available() { + eprintln!("skip: no initdb on PATH"); + return None; + } let mut cfg = ShadowConfig::new(tmp.path().join("data"), tmp.path().join("filtered")); cfg.port = port; cfg.socket_dir = tmp.path().join("sock"); cfg.ctl_timeout = Duration::from_secs(60); + let mut bridge = BridgeConf::in_dir(&cfg.socket_dir); + bridge.library_dir = Some(pgext_dir()); + cfg.bridge = Some(bridge); fs::create_dir_all(&cfg.filter_out_dir).unwrap(); fs::create_dir_all(&cfg.socket_dir).unwrap(); - Shadow::new(cfg) -} -struct StopOnDrop<'a> { - sh: &'a Shadow, + let sh = Shadow::new(cfg); + sh.initdb().expect("initdb"); + sh.write_base_conf().expect("write_base_conf"); + sh.start().expect("start"); + Some(StopOnDrop { sh }) } -impl Drop for StopOnDrop<'_> { - fn drop(&mut self) { - let _ = self.sh.stop(); - } +async fn oracle_on(sh: &Shadow) -> Oracle { + let path = sh.bridge_socket().expect("bridge configured"); + let bridge = walshadow::bridge::connect_with_budget(path, Duration::from_secs(20)) + .await + .unwrap_or_else(|e| panic!("bridge connect on {}: {e}", path.display())); + Oracle::new(Arc::new(bridge)) } /// Build short-form numeric for `42`: header 0x8000 (NUMERIC_SHORT, @@ -107,142 +142,70 @@ fn array_int4_1_2_3_bytes() -> Vec { } #[tokio::test(flavor = "current_thread")] -async fn oracle_without_extension_falls_back_to_raw_bytes() { - if !pg_available() { - eprintln!("skip: no initdb on PATH"); +async fn oracle_resolves_tier3_disk_bytes() { + let tmp = tempfile::tempdir().unwrap(); + let Some(guard) = start_pg(&tmp, SHADOW_PORT) else { return; + }; + let oracle = oracle_on(&guard.sh).await; + + let cases: [(u32, Vec, &str); 4] = [ + (NUMERICOID, numeric_42_bytes(), "42"), + (INETOID, inet_192_168_0_1_bytes(), "192.168.0.1"), + ( + INTERVALOID, + interval_1mon_2day_3us_bytes(), + // PG renders as "1 mon 2 days 00:00:00.000003" + "1 mon 2 days 00:00:00.000003", + ), + (INT4ARRAYOID, array_int4_1_2_3_bytes(), "{1,2,3}"), + ]; + for (oid, raw, want) in &cases { + let got = oracle.resolve_pending(*oid, raw).await; + assert_eq!(got.as_deref(), Some(*want), "oid {oid}"); } - let tmp = tempfile::tempdir().unwrap(); - let sh = make_pg(&tmp, SHADOW_PORT); - sh.initdb().expect("initdb"); - sh.write_base_conf().expect("write_base_conf"); - sh.start().expect("start"); - let _stop = StopOnDrop { sh: &sh }; - let conninfo = socket_conninfo( - sh.config().socket_dir.to_str().unwrap(), - sh.config().port, - "postgres", - "postgres", - ); - let oracle = Oracle::connect(&conninfo).await.expect("oracle connect"); - // Stand-alone PG without our extension. resolve_pending must - // surface None so the emitter falls back to raw bytes. - let out = oracle - .resolve_pending(3802, b"\x01opaque") - .await - .expect("resolve_pending"); - assert!(out.is_none(), "expected fallback, got {out:?}"); - use std::sync::atomic::Ordering; - assert_eq!(oracle.stats.fallback_raw.load(Ordering::Relaxed), 1); - assert_eq!(oracle.stats.resolved.load(Ordering::Relaxed), 0); - assert!(!oracle.has_extension()); + assert_eq!(oracle.stats.resolved.load(Ordering::Relaxed), 4); + assert_eq!(oracle.stats.fallback_raw.load(Ordering::Relaxed), 0); + assert_eq!(oracle.stats.errors.load(Ordering::Relaxed), 0); } #[tokio::test(flavor = "current_thread")] -async fn oracle_with_extension_resolves_tier3_disk_bytes() { - if !pg_available() { - eprintln!("skip: no initdb on PATH"); - return; - } +async fn oracle_falls_back_on_undecodable_bytes() { let tmp = tempfile::tempdir().unwrap(); - let sh = make_pg(&tmp, SHADOW_PORT + 1); - sh.initdb().expect("initdb"); - sh.write_base_conf().expect("write_base_conf"); - sh.start().expect("start"); - let _stop = StopOnDrop { sh: &sh }; + let Some(guard) = start_pg(&tmp, SHADOW_PORT + 1) else { + return; + }; + let oracle = oracle_on(&guard.sh).await; - // Optional extension load; skip cleanly if not installed system-wide. - match sh.try_load_oracle_extension() { - Ok(true) => {} - Ok(false) => { - eprintln!( - "skip: walshadow extension not installed on this PG \ - (run `cd pgext && sudo make install`)" - ); - return; - } - Err(e) => panic!("loading extension: {e}"), - } + // int4 body shorter than typlen: the worker raises per item rather than + // failing the request + assert!(oracle.resolve_pending(23, b"\x00").await.is_none()); + // Type oid with no pg_type row + assert!(oracle.resolve_pending(2147483647, b"\x00").await.is_none()); + assert_eq!(oracle.stats.fallback_raw.load(Ordering::Relaxed), 2); + assert_eq!(oracle.stats.errors.load(Ordering::Relaxed), 0); - let conninfo = socket_conninfo( - sh.config().socket_dir.to_str().unwrap(), - sh.config().port, - "postgres", - "postgres", + // Item errors are per item, so the oracle still serves + assert_eq!( + oracle + .resolve_pending(NUMERICOID, &numeric_42_bytes()) + .await + .as_deref(), + Some("42"), ); - let oracle = Oracle::connect(&conninfo).await.expect("oracle connect"); - assert!(oracle.has_extension()); - - // numeric — 42 - let txt = oracle - .resolve_pending(walshadow::schema::NUMERICOID, &numeric_42_bytes()) - .await - .expect("resolve numeric") - .expect("resolved Some"); - assert_eq!(txt, "42"); - - // inet — 192.168.0.1 - let txt = oracle - .resolve_pending(walshadow::schema::INETOID, &inet_192_168_0_1_bytes()) - .await - .expect("resolve inet") - .expect("resolved Some"); - assert_eq!(txt, "192.168.0.1"); - - // interval — 1 month 2 days 3 microseconds - let txt = oracle - .resolve_pending( - walshadow::schema::INTERVALOID, - &interval_1mon_2day_3us_bytes(), - ) - .await - .expect("resolve interval") - .expect("resolved Some"); - // PG renders as "1 mon 2 days 00:00:00.000003" - assert_eq!(txt, "1 mon 2 days 00:00:00.000003"); - - // int4[] — [1, 2, 3]. typoid 1007 = INT4ARRAYOID. - let txt = oracle - .resolve_pending(1007, &array_int4_1_2_3_bytes()) - .await - .expect("resolve int4[]") - .expect("resolved Some"); - assert_eq!(txt, "{1,2,3}"); - - use std::sync::atomic::Ordering; - assert_eq!(oracle.stats.resolved.load(Ordering::Relaxed), 4); - assert_eq!(oracle.stats.fallback_raw.load(Ordering::Relaxed), 0); - assert_eq!(oracle.stats.errors.load(Ordering::Relaxed), 0); } #[tokio::test(flavor = "current_thread")] async fn oracle_resolves_pg_pending_to_text() { - if !pg_available() { - eprintln!("skip: no initdb on PATH"); - return; - } let tmp = tempfile::tempdir().unwrap(); - let sh = make_pg(&tmp, SHADOW_PORT + 2); - sh.initdb().expect("initdb"); - sh.write_base_conf().expect("write_base_conf"); - sh.start().expect("start"); - let _stop = StopOnDrop { sh: &sh }; - if !matches!(sh.try_load_oracle_extension(), Ok(true)) { - eprintln!("skip: walshadow extension not installed on this PG"); + let Some(guard) = start_pg(&tmp, SHADOW_PORT + 2) else { return; - } - - let conninfo = socket_conninfo( - sh.config().socket_dir.to_str().unwrap(), - sh.config().port, - "postgres", - "postgres", - ); - let oracle = Arc::new(Oracle::connect(&conninfo).await.expect("oracle connect")); + }; + let oracle = oracle_on(&guard.sh).await; - // Wire one PgPending column (numeric 42) through the decode pool's - // resolution path. + // Wire two PgPending columns through the decode pool's resolution path; + // one request must cover the whole tuple. let mut committed = CommittedTuple { decoded: DecodedHeap { rfn: RelFileNode { @@ -254,10 +217,17 @@ async fn oracle_resolves_pg_pending_to_text() { source_lsn: 0xDEADBEEF, op: HeapOp::Insert, new: Some(DecodedTuple { - columns: vec![Some(ColumnValue::PgPending { - type_oid: walshadow::schema::NUMERICOID, - raw: numeric_42_bytes(), - })], + columns: vec![ + Some(ColumnValue::PgPending { + type_oid: NUMERICOID, + raw: numeric_42_bytes(), + }), + Some(ColumnValue::Int4(7)), + Some(ColumnValue::Unsupported { + type_oid: INT4ARRAYOID, + raw: array_int4_1_2_3_bytes(), + }), + ], partial: false, }), old: None, @@ -270,102 +240,59 @@ async fn oracle_resolves_pg_pending_to_text() { } let new = committed.decoded.new.as_ref().unwrap(); - match &new.columns[0] { - Some(ColumnValue::Text(s)) => assert_eq!(s, "42"), - other => panic!("expected Text(\"42\"), got {other:?}"), - } - use std::sync::atomic::Ordering; - assert_eq!(oracle.stats.resolved.load(Ordering::Relaxed), 1); + assert!( + matches!(&new.columns[0], Some(ColumnValue::Text(s)) if s == "42"), + "got {:?}", + new.columns[0], + ); + assert!(matches!(&new.columns[1], Some(ColumnValue::Int4(7)))); + assert!( + matches!(&new.columns[2], Some(ColumnValue::Text(s)) if s == "{1,2,3}"), + "got {:?}", + new.columns[2], + ); + assert_eq!(oracle.stats.resolved.load(Ordering::Relaxed), 2); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn oracle_resolve_reconnects_after_backend_restart() { - if !pg_available() { - eprintln!("skip: no initdb on PATH"); - return; - } +async fn oracle_recovers_after_cluster_restart() { let tmp = tempfile::tempdir().unwrap(); - let sh = make_pg(&tmp, SHADOW_PORT + 4); - sh.initdb().expect("initdb"); - sh.write_base_conf().expect("write_base_conf"); - sh.start().expect("start"); - let _stop = StopOnDrop { sh: &sh }; - if !matches!(sh.try_load_oracle_extension(), Ok(true)) { - eprintln!("skip: walshadow extension not installed on this PG"); + let Some(guard) = start_pg(&tmp, SHADOW_PORT + 3) else { return; - } - let conninfo = socket_conninfo( - sh.config().socket_dir.to_str().unwrap(), - sh.config().port, - "postgres", - "postgres", - ); - let oracle = Oracle::connect(&conninfo).await.expect("oracle connect"); - use walshadow::schema::NUMERICOID; - assert_eq!( - oracle - .resolve_pending(NUMERICOID, &numeric_42_bytes()) - .await - .unwrap() - .as_deref(), - Some("42"), - ); - - sh.stop().expect("stop"); - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - sh.start().expect("restart"); - + }; + let oracle = oracle_on(&guard.sh).await; assert_eq!( oracle .resolve_pending(NUMERICOID, &numeric_42_bytes()) .await - .unwrap() .as_deref(), Some("42"), - "resolve recovers after reconnect", ); -} -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn oracle_resolve_errors_when_backend_down() { - if !pg_available() { - eprintln!("skip: no initdb on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let sh = make_pg(&tmp, SHADOW_PORT + 5); - sh.initdb().expect("initdb"); - sh.write_base_conf().expect("write_base_conf"); - sh.start().expect("start"); - let _stop = StopOnDrop { sh: &sh }; - if !matches!(sh.try_load_oracle_extension(), Ok(true)) { - eprintln!("skip: walshadow extension not installed on this PG"); - return; - } - let conninfo = socket_conninfo( - sh.config().socket_dir.to_str().unwrap(), - sh.config().port, - "postgres", - "postgres", - ); - let oracle = Oracle::connect(&conninfo).await.expect("oracle connect"); - use std::sync::atomic::Ordering; - use walshadow::schema::NUMERICOID; + guard.sh.stop().expect("stop"); assert!( oracle .resolve_pending(NUMERICOID, &numeric_42_bytes()) .await - .unwrap() - .is_some(), + .is_none(), + "no resolution while the cluster is down", ); - - sh.stop().expect("stop"); - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let got = oracle - .resolve_pending(NUMERICOID, &numeric_42_bytes()) - .await - .unwrap(); - assert!(got.is_none(), "no resolution when backend down"); assert!(oracle.stats.errors.load(Ordering::Relaxed) >= 1); + + guard.sh.start().expect("restart"); + // Postmaster is up before the worker has re-bound its socket + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + let got = oracle + .resolve_pending(NUMERICOID, &numeric_42_bytes()) + .await; + if got.as_deref() == Some("42") { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "oracle never recovered after restart", + ); + tokio::time::sleep(Duration::from_millis(200)).await; + } } diff --git a/tests/oracle_types_e2e.rs b/tests/oracle_types_e2e.rs index 239ba77..da781d2 100644 --- a/tests/oracle_types_e2e.rs +++ b/tests/oracle_types_e2e.rs @@ -1,8 +1,8 @@ //! Oracle-path types (arrays/enums/geometric/pgvector), end-to-end: they decode -//! to `PgPending`/`Unsupported` and resolve via the shadow's `walshadow` -//! extension, created on the source pre-basebackup. Skipped unless `walshadow` -//! is installed (`cd pgext && sudo make install`); pgvector also needs -//! `vector`. Resolved text matches PG `typoutput`. +//! to `PgPending`/`Unsupported` and resolve through the bridge worker preloaded +//! into the shadow standby, so `pgext` must be built (`make -C pgext`); +//! pgvector also needs `vector` installed on source. Resolved text matches PG +//! `typoutput`. #![cfg(target_os = "linux")] @@ -18,7 +18,6 @@ use std::time::Duration; use walshadow::mapping::ColumnMapping; use walshadow::mapping::TableTarget; use walshadow::oracle::Oracle; -use walshadow::pg::socket_conninfo; use walshadow::schema::RelName; use walshadow::shadow::Shadow; @@ -111,7 +110,14 @@ async fn run_oracle( shadow_filter_dir, }, shadow_stream_state, - ) = fx::bootstrap_clusters(&tmp, schema_sql, slot.source, slot.shadow, slot.walsender).await; + ) = fx::bootstrap_clusters_with_bridge( + &tmp, + schema_sql, + slot.source, + slot.shadow, + slot.walsender, + ) + .await; let ch_tmp = tempfile::tempdir().unwrap(); let ch = fx::ChServer::spawn(ch_tmp, slot.ch_tcp, slot.ch_http).expect("spawn ch"); @@ -119,17 +125,16 @@ async fn run_oracle( .expect("create db"); ch.query(ch_create_sql).expect("create dest table"); - let conninfo = socket_conninfo( - shadow.config().socket_dir.to_str().unwrap(), - shadow.config().port, - "postgres", - "postgres", - ); - let oracle = Oracle::connect(&conninfo).await.expect("oracle connect"); + // Worker binds only once recovery reaches consistency, so budget the dial + let socket = shadow.bridge_socket().expect("bridge configured"); + let bridge = walshadow::bridge::connect_with_budget(socket, Duration::from_secs(30)) + .await + .expect("bridge connect"); assert!( - oracle.has_extension(), - "shadow must expose walshadow_decode_disk", + bridge.info().expect("hello").in_recovery, + "shadow must serve decode while in recovery", ); + let oracle = Oracle::new(Arc::new(bridge)); let mut pipeline = fx::build_pipeline_with_oracle( fx::BuildPipelineArgs { @@ -166,15 +171,13 @@ async fn run_oracle( #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn arrays_resolve_via_oracle() { - if skip_gate() || !extension_available("walshadow") { - eprintln!("skip: walshadow extension not installed"); + if skip_gate() { return; } let (source, ch, _tmp) = run_oracle( SLOT_ARR, "walshadow-oracle-arrays", - "CREATE EXTENSION walshadow;\n\ - CREATE TABLE public.arr (id int PRIMARY KEY, ints int[], texts text[], nums numeric[]);\n", + "CREATE TABLE public.arr (id int PRIMARY KEY, ints int[], texts text[], nums numeric[]);\n", "CREATE OR REPLACE TABLE walshadow_test.arr (\ id Int32, ints Nullable(String), texts Nullable(String), nums Nullable(String),\ _lsn UInt64, _xid UInt32, _commit_ts DateTime64(6, 'UTC'), _is_deleted Bool\ @@ -219,15 +222,13 @@ async fn arrays_resolve_via_oracle() { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn enums_resolve_via_oracle() { - if skip_gate() || !extension_available("walshadow") { - eprintln!("skip: walshadow extension not installed"); + if skip_gate() { return; } let (source, ch, _tmp) = run_oracle( SLOT_ENUM, "walshadow-oracle-enums", - "CREATE EXTENSION walshadow;\n\ - CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy');\n\ + "CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy');\n\ CREATE TABLE public.en (id int PRIMARY KEY, m mood, ms mood[]);\n", "CREATE OR REPLACE TABLE walshadow_test.en (\ id Int32, m Nullable(String), ms Nullable(String),\ @@ -266,15 +267,13 @@ async fn enums_resolve_via_oracle() { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn geometric_types_resolve_via_oracle() { - if skip_gate() || !extension_available("walshadow") { - eprintln!("skip: walshadow extension not installed"); + if skip_gate() { return; } let (source, ch, _tmp) = run_oracle( SLOT_GEO, "walshadow-oracle-geo", - "CREATE EXTENSION walshadow;\n\ - CREATE TABLE public.geo (\ + "CREATE TABLE public.geo (\ id int PRIMARY KEY, p point, ln line, ls lseg, bx box, \ pth path, poly polygon, c circle);\n", "CREATE OR REPLACE TABLE walshadow_test.geo (\ @@ -318,15 +317,14 @@ async fn geometric_types_resolve_via_oracle() { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn pgvector_resolves_via_oracle() { - if skip_gate() || !extension_available("walshadow") || !extension_available("vector") { - eprintln!("skip: walshadow or vector extension not installed"); + if skip_gate() || !extension_available("vector") { + eprintln!("skip: vector extension not installed"); return; } let (source, ch, _tmp) = run_oracle( SLOT_VEC, "walshadow-oracle-vector", - "CREATE EXTENSION walshadow;\n\ - CREATE EXTENSION vector;\n\ + "CREATE EXTENSION vector;\n\ CREATE TABLE public.vec (\ id int PRIMARY KEY, v vector(3), hv halfvec(3), sv sparsevec(5));\n", "CREATE OR REPLACE TABLE walshadow_test.vec (\ @@ -358,15 +356,13 @@ async fn pgvector_resolves_via_oracle() { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn array_update_under_rif_resolves_old_tuple() { - if skip_gate() || !extension_available("walshadow") { - eprintln!("skip: walshadow extension not installed"); + if skip_gate() { return; } let (source, ch, _tmp) = run_oracle( SLOT_RIF, "walshadow-oracle-rif", - "CREATE EXTENSION walshadow;\n\ - CREATE TABLE public.arr (id int PRIMARY KEY, ints int[]);\n\ + "CREATE TABLE public.arr (id int PRIMARY KEY, ints int[]);\n\ ALTER TABLE public.arr REPLICA IDENTITY FULL;\n", "CREATE OR REPLACE TABLE walshadow_test.arr (\ id Int32, ints Nullable(String),\ diff --git a/tests/pending_capture_e2e.rs b/tests/pending_capture_e2e.rs new file mode 100644 index 0000000..1e9c55b --- /dev/null +++ b/tests/pending_capture_e2e.rs @@ -0,0 +1,343 @@ +//! Pending catalog capture end to end against real WAL: the writing +//! transaction's own uncommitted catalog rows, read at each command boundary +//! off the bridge worker, folded into the commit's descriptor-log batch. +//! +//! Drills: +//! +//! 1. `pending_timeline_decodes_the_post_ddl_row` — an in-place type change +//! no single descriptor provably reads, then a row past it. The timeline +//! answers from the command boundary on, so the fence shrinks to the run +//! before it and the row lands. +//! `dirty_admission_e2e::physical_in_place_alter_fences_deferred_rows` is +//! the control: the same shape with the feature off fences the whole dirty +//! interval and stops the stream +//! 2. `relation_born_in_xact_keeps_a_slot_per_boundary` — two shapes of a +//! relation created in the same transaction, one slot each, the first at +//! the generation's smgr marker +//! 3. `savepoint_rollback_drops_its_pending_slots` — a rolled-back savepoint's +//! shapes never reach the durable log, and its column never reaches CH +//! +//! `varchar(10)` → `text` is binary coercible, so PG rewrites `atttypid` in +//! place: same filenode, same walk fields, different type. That is the +//! physically-unproven in-place transition, reachable without a rewrite. + +#![cfg(target_os = "linux")] + +#[path = "common/inproc_harness.rs"] +mod fx; + +use fx::spawn_txn; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use walshadow::mapping::NamespaceMapping; +use walshadow::shadow::Shadow; + +const SLOT_COVERED: PortSlot = PortSlot { + source: 18100, + shadow: 18101, + ch_tcp: 18102, + ch_http: 18103, + walsender: 18107, +}; +const SLOT_SAVEPOINT: PortSlot = PortSlot { + source: 18110, + shadow: 18111, + ch_tcp: 18112, + ch_http: 18113, + walsender: 18117, +}; +const SLOT_BORN: PortSlot = PortSlot { + source: 18120, + shadow: 18121, + ch_tcp: 18122, + ch_http: 18123, + walsender: 18127, +}; + +struct PortSlot { + source: u16, + shadow: u16, + ch_tcp: u16, + ch_http: u16, + walsender: u16, +} + +fn skip_gate() -> bool { + if !fx::pg_available() || !fx::pg_basebackup_available() || !fx::clickhouse_available() { + eprintln!("skip: missing initdb / pg_basebackup / clickhouse on PATH"); + return true; + } + false +} + +struct Drill { + source: Shadow, + shadow: Shadow, + ch: fx::ChServer, + pipeline: fx::Pipeline, + _tmp: tempfile::TempDir, +} + +async fn build_drill(slot: PortSlot, schema_sql: &str, app_name: &str) -> Drill { + let tmp = tempfile::tempdir().unwrap(); + let ( + fx::BootstrappedClusters { + source, + shadow, + shadow_filter_dir, + }, + shadow_stream_state, + ) = fx::bootstrap_clusters_with_bridge( + &tmp, + schema_sql, + slot.source, + slot.shadow, + slot.walsender, + ) + .await; + + let ch_tmp = tempfile::tempdir().unwrap(); + let ch = fx::ChServer::spawn(ch_tmp, slot.ch_tcp, slot.ch_http).expect("spawn ch"); + ch.query("CREATE DATABASE IF NOT EXISTS walshadow_test") + .expect("create db"); + + let mut ddl_args = fx::DdlPipelineArgs::default(); + ddl_args.namespaces.insert( + "pc".into(), + NamespaceMapping { + target_database: Some("walshadow_test".into()), + auto_create: true, + drop_table_strategy: None, + }, + ); + + let pipeline = fx::build_pipeline(fx::BuildPipelineArgs { + tmp: &tmp, + source: &source, + shadow: &shadow, + shadow_filter_dir: &shadow_filter_dir, + shadow_stream_state, + ch_database: "walshadow_test", + ch_tcp_port: slot.ch_tcp, + mappings: vec![], + app_name, + ddl: Some(ddl_args), + }) + .await; + + Drill { + source, + shadow, + ch, + pipeline, + _tmp: tmp, + } +} + +async fn pump_and_drain(drill: &mut Drill) { + let shipped = fx::pump_segments(&mut drill.pipeline, 1, Duration::from_secs(45)).await; + assert!(shipped >= 1, "expected ≥1 shipped segment, got {shipped}"); + let target = drill.pipeline.stream.dispatched_lsn(); + let observed = drill + .shadow + .wait_for_replay(target, Duration::from_secs(30)) + .expect("shadow replay"); + assert!(observed >= target); +} + +fn capture_stats(drill: &Drill) -> std::sync::Arc { + drill + .pipeline + .sinks + .capture + .as_ref() + .expect("capture wired") + .stats_handle() +} + +fn load(c: &std::sync::atomic::AtomicU64) -> u64 { + c.load(Ordering::Relaxed) +} + +/// Row written after an unproven in-place change decodes under the shape the +/// command boundary captured, where without the timeline the fence would +/// cover it and stop the stream. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn pending_timeline_decodes_the_post_ddl_row() { + if skip_gate() { + return; + } + let mut drill = build_drill( + SLOT_COVERED, + "CREATE SCHEMA pc;\n\ + CREATE TABLE pc.t (id bigint PRIMARY KEY, v varchar(10));\n", + "walshadow-pending-covered", + ) + .await; + let capture = capture_stats(&drill); + + let driver = spawn_txn( + &drill.source, + "BEGIN;\n\ + INSERT INTO pc.t (id, v) VALUES (1, 'pre');\n\ + ALTER TABLE pc.t ALTER COLUMN v TYPE text;\n\ + INSERT INTO pc.t (id, v) VALUES (2, 'post');\n\ + COMMIT;\n\ + SELECT pg_switch_wal();\n", + ); + pump_and_drain(&mut drill).await; + let _ = driver.join(); + + drill.pipeline.shutdown().await.expect("pipeline drains"); + let _ = drill.shadow.stop(); + let _ = drill.source.stop(); + + fx::wait_query( + &drill.ch, + "SELECT arrayStringConcat(groupArray(c), ',') FROM (\ + SELECT concat(toString(id), '=', argMax(v, _lsn)) AS c \ + FROM walshadow_test.t WHERE _is_deleted = 0 \ + GROUP BY id ORDER BY id)", + "1=pre,2=post", + "row past the command boundary decodes under the shape it saw", + ) + .await; + assert!( + load(&capture.pending_captures) >= 1, + "command boundary read into the timeline", + ); + assert!( + load(&capture.pending_entries_promoted) >= 1, + "its shape folded into the commit batch", + ); + assert!( + load(&capture.pending_holds) >= 1, + "command boundary parked publication", + ); + assert_eq!( + capture.pending_degraded.iter().map(load).sum::(), + 0, + "nothing degraded this transaction", + ); + assert!( + load(&capture.ambiguities_published) >= 1, + "the run before the first boundary is still fenced", + ); +} + +/// A relation born inside the transaction takes two shapes across two +/// command boundaries. The first claims the generation's smgr marker — rows +/// can't precede the create — and the second answers from its own boundary, +/// so both land in the batch instead of collapsing onto one position. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn relation_born_in_xact_keeps_a_slot_per_boundary() { + if skip_gate() { + return; + } + let mut drill = build_drill(SLOT_BORN, "CREATE SCHEMA pc;\n", "walshadow-pending-born").await; + let capture = capture_stats(&drill); + + let driver = spawn_txn( + &drill.source, + "BEGIN;\n\ + CREATE TABLE pc.born (id bigint PRIMARY KEY, v text);\n\ + INSERT INTO pc.born (id, v) VALUES (1, 'first');\n\ + ALTER TABLE pc.born ADD COLUMN extra text;\n\ + INSERT INTO pc.born (id, v, extra) VALUES (2, 'second', 'e2');\n\ + COMMIT;\n\ + SELECT pg_switch_wal();\n", + ); + pump_and_drain(&mut drill).await; + let _ = driver.join(); + + drill.pipeline.shutdown().await.expect("pipeline drains"); + let _ = drill.shadow.stop(); + let _ = drill.source.stop(); + + fx::wait_query( + &drill.ch, + // `concat` over a NULL yields NULL and `groupArray` then skips the + // row, so the pre-ALTER row's absent column needs a substitute + "SELECT arrayStringConcat(groupArray(c), ',') FROM (\ + SELECT concat(toString(id), '=', argMax(v, _lsn), '/', \ + ifNull(argMax(extra, _lsn), '-')) AS c \ + FROM walshadow_test.born WHERE _is_deleted = 0 \ + GROUP BY id ORDER BY id)", + "1=first/-,2=second/e2", + "both shapes of a relation born in the xact reach CH", + ) + .await; + assert!( + load(&capture.pending_captures) >= 2, + "one boundary per command that touched the relation", + ); + assert!( + load(&capture.pending_entries_promoted) >= 2, + "two shapes, two positions — not one slot overwriting the other", + ); +} + +/// A rolled-back savepoint's shapes die on the pump, before the commit that +/// would promote them: the reverted column reaches neither the durable log +/// nor CH. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn savepoint_rollback_drops_its_pending_slots() { + if skip_gate() { + return; + } + let mut drill = build_drill( + SLOT_SAVEPOINT, + "CREATE SCHEMA pc;\n\ + CREATE TABLE pc.t (id bigint PRIMARY KEY, v text);\n", + "walshadow-pending-savepoint", + ) + .await; + let capture = capture_stats(&drill); + + let driver = spawn_txn( + &drill.source, + "BEGIN;\n\ + SAVEPOINT sp;\n\ + ALTER TABLE pc.t ADD COLUMN reverted text;\n\ + ROLLBACK TO SAVEPOINT sp;\n\ + INSERT INTO pc.t (id, v) VALUES (1, 'after');\n\ + COMMIT;\n\ + SELECT pg_switch_wal();\n", + ); + pump_and_drain(&mut drill).await; + let _ = driver.join(); + + drill.pipeline.shutdown().await.expect("pipeline drains"); + let _ = drill.shadow.stop(); + let _ = drill.source.stop(); + + fx::wait_query( + &drill.ch, + "SELECT count() FROM system.columns \ + WHERE database = 'walshadow_test' AND table = 't' AND name = 'reverted'", + "0", + "rolled-back column never reaches CH", + ) + .await; + fx::wait_query( + &drill.ch, + "SELECT argMax(v, _lsn) FROM walshadow_test.t WHERE id = 1 AND _is_deleted = 0", + "after", + "post-rollback row delivers under the unmodified shape", + ) + .await; + assert!( + load(&capture.pending_captures) >= 1, + "the savepoint's command boundary was read", + ); + assert!( + load(&capture.pending_entries_dropped_abort) >= 1, + "its slots dropped with the aborted subtree", + ); + assert_eq!( + load(&capture.pending_entries_promoted), + 0, + "nothing from the aborted subtree became durable", + ); +} diff --git a/tests/pgbench_acceptance.rs b/tests/pgbench_acceptance.rs index 1fb7d8e..5063d2f 100644 --- a/tests/pgbench_acceptance.rs +++ b/tests/pgbench_acceptance.rs @@ -287,6 +287,9 @@ async fn run_ddl_intermix(ports: Ports, decoder_pool: usize, inserter_pool: usiz tracing::warn!("skip: no pgbench binary on PATH"); return; } + // Shadow is daemon-owned, so the daemon writes the preload line itself and + // only needs to be told where the un-installed module sits + let pgext_dir = fx::pgext_dir(); let tmp = tempfile::tempdir().unwrap(); @@ -399,6 +402,8 @@ async fn run_ddl_intermix(ports: Ports, decoder_pool: usize, inserter_pool: usiz &decoder_pool_arg, "--inserter-pool-size", &inserter_pool_arg, + "--bridge-lib-dir", + pgext_dir.to_str().unwrap(), ]) // CI sets `WALSHADOW_ARTIFACT_DIR`; bump the daemon to trace // for `xact_buffer` so a stalled commit pipeline can be read diff --git a/tests/shadow_catalog.rs b/tests/shadow_catalog.rs index 6b59570..aa1f594 100644 --- a/tests/shadow_catalog.rs +++ b/tests/shadow_catalog.rs @@ -7,11 +7,13 @@ //! ports so cargo's parallel runner doesn't collide them. use std::process::Command; +use std::sync::Arc; use std::time::Duration; +use walshadow::bridge::Bridge; use walshadow::pg::socket_conninfo; use walshadow::schema::{RelName, ReplIdent}; -use walshadow::shadow::{Shadow, ShadowConfig}; +use walshadow::shadow::{BridgeConf, Shadow, ShadowConfig}; use walshadow::shadow_catalog::{ CatalogError, ShadowCatalog, ShadowCatalogConfig, with_transient_retry, }; @@ -29,6 +31,14 @@ fn make_shadow(tmp: &tempfile::TempDir, port: u16) -> Shadow { cfg.port = port; cfg.socket_dir = tmp.path().join("sock"); cfg.ctl_timeout = Duration::from_secs(30); + let mut bridge = BridgeConf::in_dir(&cfg.socket_dir); + let build_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("pgext"); + assert!( + build_dir.join("walshadow.so").is_file(), + "pgext/walshadow.so missing, run `make -C pgext`" + ); + bridge.library_dir = Some(build_dir); + cfg.bridge = Some(bridge); std::fs::create_dir_all(&cfg.filter_out_dir).unwrap(); std::fs::create_dir_all(&cfg.socket_dir).unwrap(); Shadow::new(cfg) @@ -61,11 +71,20 @@ async fn open_catalog(shadow: &Shadow, replay_timeout: Duration) -> ShadowCatalo replay_poll: Duration::from_millis(20), ..Default::default() }; - ShadowCatalog::connect(&conninfo, cat_cfg) + ShadowCatalog::connect(&conninfo, cat_cfg, open_bridge(shadow).await) .await .expect("catalog connect") } +async fn open_bridge(shadow: &Shadow) -> Arc { + let path = shadow.bridge_socket().expect("bridge configured"); + Arc::new( + walshadow::bridge::connect_with_budget(path, Duration::from_secs(20)) + .await + .unwrap_or_else(|e| panic!("bridge connect on {}: {e}", path.display())), + ) +} + fn relation_oid(shadow: &Shadow, qualified: &str) -> u32 { shadow .psql_one(&format!("SELECT '{qualified}'::regclass::oid::int8")) @@ -233,6 +252,7 @@ async fn with_transient_retry_outlasts_a_pg_restart() { "postgres", "postgres", ); + let bridge = open_bridge(&shadow).await; // Stop PG so the first connect attempts fail; restart in a background // task after a short delay. with_transient_retry must keep retrying @@ -273,6 +293,7 @@ async fn with_transient_retry_outlasts_a_pg_restart() { replay_poll: Duration::from_millis(20), ..Default::default() }, + bridge.clone(), ) .await }, diff --git a/tests/wal_stream_throughput.rs b/tests/wal_stream_throughput.rs index 1ff7267..52544a4 100644 --- a/tests/wal_stream_throughput.rs +++ b/tests/wal_stream_throughput.rs @@ -325,6 +325,7 @@ async fn pump_throughput_breakdown() { route: r.route, catalog_boundary: r.catalog_boundary, boundary_info: r.boundary_info.clone(), + aborted_tree: r.aborted_tree.clone(), defer_catalog_decode: r.defer_catalog_decode, }; // Push then immediately pop to drop, so we measure diff --git a/tests/xact_buffer.rs b/tests/xact_buffer.rs index 2831329..b640a00 100644 --- a/tests/xact_buffer.rs +++ b/tests/xact_buffer.rs @@ -27,7 +27,7 @@ use walshadow::heap_decoder::{ ColumnValue, CommittedTuple, DecodedHeap, DecodedTuple, DescribedHeap, HeapOp, ToastPointer, }; use walshadow::pg::socket_conninfo; -use walshadow::shadow::{Shadow, ShadowConfig}; +use walshadow::shadow::{BridgeConf, Shadow, ShadowConfig}; use walshadow::shadow_catalog::{ShadowCatalog, ShadowCatalogConfig}; use walshadow::spill::ToastChunk; use walshadow::toast::{ChunkRefMap, MemChunkStore, ToastResolver}; @@ -48,6 +48,14 @@ fn make_shadow(tmp: &tempfile::TempDir, port: u16) -> Shadow { cfg.port = port; cfg.socket_dir = tmp.path().join("sock"); cfg.ctl_timeout = Duration::from_secs(30); + let mut bridge = BridgeConf::in_dir(&cfg.socket_dir); + let build_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("pgext"); + assert!( + build_dir.join("walshadow.so").is_file(), + "pgext/walshadow.so missing, run `make -C pgext`" + ); + bridge.library_dir = Some(build_dir); + cfg.bridge = Some(bridge); std::fs::create_dir_all(&cfg.filter_out_dir).unwrap(); std::fs::create_dir_all(&cfg.socket_dir).unwrap(); Shadow::new(cfg) @@ -80,7 +88,15 @@ async fn open_catalog(shadow: &Shadow) -> ShadowCatalog { replay_poll: Duration::from_millis(20), ..Default::default() }; - ShadowCatalog::connect(&conninfo, cat_cfg) + let bridge = Arc::new( + walshadow::bridge::connect_with_budget( + shadow.bridge_socket().expect("bridge configured"), + Duration::from_secs(20), + ) + .await + .expect("bridge connect"), + ); + ShadowCatalog::connect(&conninfo, cat_cfg, bridge) .await .expect("catalog connect") } @@ -371,6 +387,7 @@ async fn defer_catalog_decode_stashes_raw_and_commit_fences() { route: Route::ToDecoder, catalog_boundary: false, boundary_info: None, + aborted_tree: None, defer_catalog_decode: true, }; sink.on_record(&record).await.unwrap(); @@ -388,9 +405,17 @@ async fn defer_catalog_decode_stashes_raw_and_commit_fences() { "defer path, not marker path" ); let stats = Arc::new(EmitterStats::default()); - resolve_stash(&buffer, &log, 77, &[], 1000, stats.clone()) - .await - .unwrap(); + resolve_stash( + &buffer, + &log, + &Default::default(), + 77, + &[], + 1000, + stats.clone(), + ) + .await + .unwrap(); let mut b = buffer.lock().await; let err = drain_all(&mut b, &ToastResolver::disabled(), 77, 0, 1000, &[]) .await