diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e69d390e..364476e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,18 @@ jobs: pg-major: [16, 17, 18] steps: + # ubuntu-latest ships ~14GB free on /; `cargo --all-targets` links + # every integration test as its own static binary and can exhaust it + # mid-link. ENOSPC surfaces as an lld SIGBUS + a spurious "report to + # LLVM" banner. Fast `rm -rf` of the big preinstalled dirs + # (android/dotnet/haskell, ~21GB) buys enough room; skip large-packages + # (slow apt remove, minutes) and swap-storage (frees /mnt, not /). + - name: Free disk space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + large-packages: false + swap-storage: false + - name: Checkout walshadow uses: actions/checkout@v7 with: @@ -217,6 +229,12 @@ jobs: timeout-minutes: 60 steps: + - name: Free disk space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + large-packages: false + swap-storage: false + - name: Checkout walshadow uses: actions/checkout@v7 with: diff --git a/architecture/decoder.dot b/architecture/decoder.dot index f89e9e92..07462fc8 100644 --- a/architecture/decoder.dot +++ b/architecture/decoder.dot @@ -27,13 +27,14 @@ digraph decoder { label="BufferingDecoderSink::on_record (xact_buffer.rs)"; style="rounded,filled"; color="#4c4641"; fillcolor="#34302c"; fontcolor="#ECE1D7"; sink [label="on_record\nSMGR CREATE?\nHeap TRUNCATE?\nother heap op?", fillcolor="#4D4128"]; marker [label="note_smgr_create\nmain-fork generation marker\nproves stash completeness", fillcolor="#4D4128"]; - trunc [label="handle_truncate\nparse_xl_heap_truncate\nwait_for_replay(source_lsn)\nfor relid in relids:\n relation_by_oid → fan out\n HeapOp::Truncate\n(kind in {'r','p'} only)", fillcolor="#4D4128"]; - relat [label="ShadowCatalog::relation_at(rfn, lsn)\nLRU + libpq fetch", fillcolor="#4D4D28"]; - stash [label="stash_invisible\nSpillEntry::Raw\nmain data + block data/images\ncommit: relation_at(rfn, commit_lsn)\ntoast decode | ordinary skip | discard", fillcolor="#4D4128"]; + trunc [label="handle_truncate\nparse_xl_heap_truncate\nfor relid in relids:\n descriptor_by_oid_at_spanned\n → fan out HeapOp::Truncate\n(kind in {'r','p'} only)", fillcolor="#4D4128"]; + relat [label="DescriptorLog::\ndescriptor_at_spanned(rfn, lsn)\nwait-free interval lookup", fillcolor="#4D4D28"]; + stash [label="raw stash\nSpillEntry::Raw\nmain data + block data/images + xid\ncommit: resolve_stash at next_lsn\ntoast | ordinary decode | discard\nAmbiguous = fatal", fillcolor="#4D4128"]; sink -> marker [label="SMGR CREATE", style=dashed]; sink -> trunc [label="0x30 TRUNCATE", style=dashed]; sink -> relat [label="other heap ops"]; - relat -> stash [label="NotFoundByFilenode\nor known candidate", style=dashed]; + sink -> stash [label="dirty family\n(defer_catalog_decode)", style=dashed]; + relat -> stash [label="NotCovered / Dropped /\nAmbiguous, or marker\ncandidate", style=dashed]; } record -> sink; diff --git a/architecture/decoder.svg b/architecture/decoder.svg index 03c866a3..e88c288c 100644 --- a/architecture/decoder.svg +++ b/architecture/decoder.svg @@ -4,581 +4,591 @@ - - + + decoder - -walshadow heap decoder — record → DecodedHeap dispatch + type matrix + +walshadow heap decoder — record → DecodedHeap dispatch + type matrix cluster_sink - -BufferingDecoderSink::on_record (xact_buffer.rs) + +BufferingDecoderSink::on_record (xact_buffer.rs) cluster_ops - -op dispatch — info & XLOG_HEAP_OPMASK + +op dispatch — info & XLOG_HEAP_OPMASK cluster_types - -decode_one_value — per-OID dispatch + +decode_one_value — per-OID dispatch cluster_replident - -old-tuple shape — relreplident × flags + +old-tuple shape — relreplident × flags cluster_missing - -missing_value_for(att) — fast-path ADD COLUMN ... DEFAULT k + +missing_value_for(att) — fast-path ADD COLUMN ... DEFAULT k record - -WAL Record -RM_SMGR / RM_HEAP / RM_HEAP2 -rfn = blocks[0].location.rel -xid, source_lsn, info + +WAL Record +RM_SMGR / RM_HEAP / RM_HEAP2 +rfn = blocks[0].location.rel +xid, source_lsn, info sink - -on_record -SMGR CREATE? -Heap TRUNCATE? -other heap op? + +on_record +SMGR CREATE? +Heap TRUNCATE? +other heap op? - + record->sink - - + + marker - -note_smgr_create -main-fork generation marker -proves stash completeness + +note_smgr_create +main-fork generation marker +proves stash completeness sink->marker - - -SMGR CREATE + + +SMGR CREATE trunc - -handle_truncate -parse_xl_heap_truncate -wait_for_replay(source_lsn) -for relid in relids: -  relation_by_oid → fan out -  HeapOp::Truncate -(kind in {'r','p'} only) + +handle_truncate +parse_xl_heap_truncate +for relid in relids: +  descriptor_by_oid_at_spanned +  → fan out HeapOp::Truncate +(kind in {'r','p'} only) sink->trunc - - -0x30 TRUNCATE + + +0x30 TRUNCATE relat - -ShadowCatalog::relation_at(rfn, lsn) -LRU + libpq fetch + +DescriptorLog:: +descriptor_at_spanned(rfn, lsn) +wait-free interval lookup sink->relat - - -other heap ops + + +other heap ops + + + +stash + +raw stash +SpillEntry::Raw +main data + block data/images + xid +commit: resolve_stash at next_lsn +toast | ordinary decode | discard +Ambiguous = fatal + + + +sink->stash + + +dirty family +(defer_catalog_decode) xactbuf - - -XactBuffer::on_heap -per-xid bucket -+ TOAST reassembly -+ subxact tracker + + +XactBuffer::on_heap +per-xid bucket ++ TOAST reassembly ++ subxact tracker - + marker->xactbuf - - -marker + stash rfn + + +marker + stash rfn decoded - -DecodedHeap { -  rfn, xid, source_lsn, -  op, new, old -} -SmallVec<[_; 1]> stack -(spills only on multi) + +DecodedHeap { +  rfn, xid, source_lsn, +  op, new, old +} +SmallVec<[_; 1]> stack +(spills only on multi) - + trunc->decoded - - -Truncate -(per relid) - - - -stash - -stash_invisible -SpillEntry::Raw -main data + block data/images -commit: relation_at(rfn, commit_lsn) -toast decode | ordinary skip | discard + + +Truncate +(per relid) - + relat->stash - - -NotFoundByFilenode -or known candidate + + +NotCovered / Dropped / +Ambiguous, or marker +candidate entry - -decode_heap_record -(heap_decoder.rs) -dispatch on rm + info & 0x70 + +decode_heap_record +(heap_decoder.rs) +dispatch on rm + info & 0x70 - + relat->entry - - -RelDescriptor + + +RelDescriptor - + stash->xactbuf - - -Raw + + +Raw ins - -INSERT 0x00 -decode_insert -block.data: -  xl_heap_header (5) -  + bitmap + col data + +INSERT 0x00 +decode_insert +block.data: +  xl_heap_header (5) +  + bitmap + col data - + entry->ins - - + + upd - -UPDATE 0x20 / HOT 0x40 -decode_update -prefix/suffix u16 from -block.data per -XLH_UPDATE_*_FROM_OLD + +UPDATE 0x20 / HOT 0x40 +decode_update +prefix/suffix u16 from +block.data per +XLH_UPDATE_*_FROM_OLD - + entry->upd - - + + del - -DELETE 0x10 -decode_delete -old tuple in main_data -when XLH_DELETE_CONTAINS_* + +DELETE 0x10 +decode_delete +old tuple in main_data +when XLH_DELETE_CONTAINS_* - + entry->del - - + + multi - -MULTI_INSERT 0x50 (Heap2) -decode_multi_insert -ntuples loop — -per-tuple xl_multi_insert_tuple -synthesises stripped 5B header + +MULTI_INSERT 0x50 (Heap2) +decode_multi_insert +ntuples loop — +per-tuple xl_multi_insert_tuple +synthesises stripped 5B header - + entry->multi - - + + skip - - - -LOCK / INPLACE / CONFIRM / -other RM_HEAP2 ops -→ empty SmallVec (silent skip) + + + +LOCK / INPLACE / CONFIRM / +other RM_HEAP2 ops +→ empty SmallVec (silent skip) - + entry->skip - - + + payload - -decode_tuple_payload -parse xl_heap_header -t_infomask2 / t_infomask / t_hoff -natts = t_infomask2 & 0x07FF -bitmap[+MAXALIGN pad] -col_data_off = 5 + (t_hoff − 23) + +decode_tuple_payload +parse xl_heap_header +t_infomask2 / t_infomask / t_hoff +natts = t_infomask2 & 0x07FF +bitmap[+MAXALIGN pad] +col_data_off = 5 + (t_hoff − 23) - + ins->payload - - + + - + upd->payload - - -cursor += -prefix/suffix + + +cursor += +prefix/suffix - + del->payload - - + + - + multi->payload - - -synth header -+ tuple body + + +synth header ++ tuple body colwalk - -per-column walk over rel.attributes -att_align_nominal (peek byte for varlena) -bitmap-clear → Some(Null) -in-prefix / past-EOF → None, partial = true -idx ≥ natts → missing_value_for(att) + +per-column walk over rel.attributes +att_align_nominal (peek byte for varlena) +bitmap-clear → Some(Null) +in-prefix / past-EOF → None, partial = true +idx ≥ natts → missing_value_for(att) - + payload->colwalk - - + + rid - - - -relreplident - - -old payload - -Default + PK - -PK cols on OLD_KEY, non-PK = Some(Null) via bitmap - -Default no-PK - -old = None - -Nothing - -old = None - -Full - -every non-dropped column - -UsingIndex - -indexed cols, others = Some(Null) + + + +relreplident + + +old payload + +Default + PK + +PK cols on OLD_KEY, non-PK = Some(Null) via bitmap + +Default no-PK + +old = None + +Nothing + +old = None + +Full + +every non-dropped column + +UsingIndex + +indexed cols, others = Some(Null) - + payload->rid - - -UPDATE/DELETE -old-tuple shape + + +UPDATE/DELETE +old-tuple shape tier1 - -Tier 1 — fixed-width -bool / char / int2/4/8 / oid -float4/8 / date / time / timestamp[tz] -timetz (12) / uuid (16) / name (64) -interval (16, via codecs) + +Tier 1 — fixed-width +bool / char / int2/4/8 / oid +float4/8 / date / time / timestamp[tz] +timetz (12) / uuid (16) / name (64) +interval (16, via codecs) - + colwalk->tier1 - - + + tier2 - -Tier 2 — varlena (typlen = −1) -decode_varlena: 1B short / 4B (un)compressed / on-disk TOAST -bytea / text / bpchar / varchar / json -invalid UTF-8 → Bytea fallback + +Tier 2 — varlena (typlen = −1) +decode_varlena: 1B short / 4B (un)compressed / on-disk TOAST +bytea / text / bpchar / varchar / json +invalid UTF-8 → Bytea fallback - + colwalk->tier2 - - + + tier3 - -Tier 3 — in-tree codecs (codecs.rs) -numeric (decode_numeric) -inet / cidr (decode_inet) -interval (decode_interval) -json (passthrough) + +Tier 3 — in-tree codecs (codecs.rs) +numeric (decode_numeric) +inet / cidr (decode_inet) +interval (decode_interval) +json (passthrough) - + colwalk->tier3 - - + + pending - -PgPending { type_oid, raw } -jsonb / range / arrays / -tsvector / vendor types → -oracle: walshadow_decode_disk(oid, bytea) + +PgPending { type_oid, raw } +jsonb / range / arrays / +tsvector / vendor types → +oracle: walshadow_decode_disk(oid, bytea) - + colwalk->pending - - + + miss - -RelAttr.missing_text = Some(t) -Tier 1 numeric → parse iN / fN -BOOLOID → PG truth set -CHAROID → first byte i8 -TEXT/VARCHAR/BPCHAR/NAME/JSON → passthrough -else → PgPending { oid, t.as_bytes() } + +RelAttr.missing_text = Some(t) +Tier 1 numeric → parse iN / fN +BOOLOID → PG truth set +CHAROID → first byte i8 +TEXT/VARCHAR/BPCHAR/NAME/JSON → passthrough +else → PgPending { oid, t.as_bytes() } - + colwalk->miss - - -idx ≥ natts -(post-ALTER -trailing attrs) + + +idx ≥ natts +(post-ALTER +trailing attrs) - + tier1->decoded - - + + toast - - - -ExternalToast(ToastPointer) -varattrib_1b_e (tag 18) -va_rawsize / va_extinfo / -va_valueid / va_toastrelid + + + +ExternalToast(ToastPointer) +varattrib_1b_e (tag 18) +va_rawsize / va_extinfo / +va_valueid / va_toastrelid - + tier2->toast - - -on-disk ptr + + +on-disk ptr - + tier2->decoded - - + + - + tier3->decoded - - + + - + pending->decoded - - + + - + decoded->xactbuf - - + + toastop - - - -relkind = 't' -INSERT → ToastChunk + TID -DELETE → ToastDelete + TID -record LSN versions mirror row + + + +relkind = 't' +INSERT → ToastChunk + TID +DELETE → ToastDelete + TID +record LSN versions mirror row - + decoded->toastop - - + + legend - - - -node fill — role - - - -WAL record ingress - - - -decoder + xact buffer (heap_decoder.rs, xact_buffer.rs) - - - -ShadowCatalog (RelDescriptor resolution) - - - -silent-skip op (no DecodedHeap emitted) - - - -oracle / missing-value bridge - - -edge colour — type tier - -━━ - -Tier 1 fixed-width (typlen > 0) - -━━ - -Tier 2 varlena (typlen = −1) — varatt headers - -━━ - -Tier 3 in-tree codecs (numeric / inet / interval / json) - -┄┄ - -PgPending → oracle (jsonb / range / array / vendor) - -┄┄ - -side branch (replica identity, read-time defaults) - - -tier matrix — handling - -Tier 1 - -inline byte read from buf[abs..abs+typlen] - -Tier 2 - -decode_varlena (short / 4B / TOAST ptr) + per-OID body - -Tier 3 - -codecs.rs (numeric base-10000, inet family+bits, interval 16B) - -PgPending - -carry raw bytes, resolve at emit via shadow PG extension + + + +node fill — role + + + +WAL record ingress + + + +decoder + xact buffer (heap_decoder.rs, xact_buffer.rs) + + + +ShadowCatalog (RelDescriptor resolution) + + + +silent-skip op (no DecodedHeap emitted) + + + +oracle / missing-value bridge + + +edge colour — type tier + +━━ + +Tier 1 fixed-width (typlen > 0) + +━━ + +Tier 2 varlena (typlen = −1) — varatt headers + +━━ + +Tier 3 in-tree codecs (numeric / inet / interval / json) + +┄┄ + +PgPending → oracle (jsonb / range / array / vendor) + +┄┄ + +side branch (replica identity, read-time defaults) + + +tier matrix — handling + +Tier 1 + +inline byte read from buf[abs..abs+typlen] + +Tier 2 + +decode_varlena (short / 4B / TOAST ptr) + per-OID body + +Tier 3 + +codecs.rs (numeric base-10000, inet family+bits, interval 16B) + +PgPending + +carry raw bytes, resolve at emit via shadow PG extension - + toastop->xactbuf - - + + diff --git a/architecture/emitter.dot b/architecture/emitter.dot index ce8555b8..d645cba2 100644 --- a/architecture/emitter.dot +++ b/architecture/emitter.dot @@ -1,13 +1,14 @@ // walshadow — CH emitter component view // Parallel decode+insert pipeline: reorder coordinator (commit order, -// DDL/TRUNCATE barrier) → decode pool ×M → InsertBatcher (per-table +// side-effect-free transaction plan then execute, DDL/TRUNCATE barrier) +// → decode pool ×M → InsertBatcher (per-table // TableEncoder, budget/deadline seal) → inserter pool ×N (one complete // INSERT per sealed batch) → ack collector (contiguous-done watermark). // Zoomed-in view of cluster_ch + cluster_ddl from internals.dot. // // regeneration spec: -// sources of truth: plans/emitter.md · src/emit/pipeline/{reorder,decode,batcher,inserter,ack,tail}.rs · src/emit/{ch_emitter,ch_ddl}.rs · src/catalog/type_bridge.rs -// subsumes: plans/emitter.md § "Stage walk" + "Barrier fence" + "Ack-LSN tracking" + "DdlApplicator" +// sources of truth: plans/emitter.md · src/emit/pipeline/{reorder,planner,plan_spool,decode,batcher,inserter,ack,tail}.rs · src/emit/{ch_emitter,ch_ddl}.rs · src/catalog/type_bridge.rs +// subsumes: plans/emitter.md § "Stage walk" + "Transaction planner" + "Barrier fence" + "Ack-LSN tracking" + "DdlApplicator" // quality bar: // - decode ×M and inserter ×N read as pools (stacked node or ×M/×N label), not single tasks // - barrier fence visually orders DDL strictly after earlier data durable (placed → FlushAll → durable) @@ -27,7 +28,9 @@ digraph emitter { style="rounded,filled"; color="#4c4641"; fillcolor="#34302c"; fontcolor="#ECE1D7"; reorder [label="transaction reorder\nwalk rows and changes in WAL order\nempty commits and aborts still advance progress\nschema and truncate changes wait for earlier rows", fillcolor="#4D4128"]; + plan [label="transaction planner (planner.rs + plan_spool.rs)\nplan first, execute after: detoast + route +\nraw decode, side-effect-free; frozen route view\n(whole-xact config granularity)\nplan ≤1 MiB resident else .plan spool\n[len|crc32c|body]*, seal frame; error = abandon,\nnothing emitted", fillcolor="#4D4128"]; cat [label="ShadowCatalog::subscribe\nSchemaEvent\nAdded / Changed / Dropped\n(unbounded mpsc, rides xact buffer\n as ordered_events)", fillcolor="#4D4D28", shape=parallelogram]; + reorder -> plan [label="drain walk\n(plan → seal → execute)"]; } // ═════ decode pool ═════ @@ -35,7 +38,7 @@ digraph emitter { label="decode pool ×M (emit/pipeline/decode.rs)"; style="rounded,filled"; color="#4c4641"; fillcolor="#34302c"; fontcolor="#ECE1D7"; - decode [label="decode worker ×M\ndetoast: ChunkMap, then store as-of fetch\nresolve_at_pooled; mapping\noracle PgPending + sampled validate\nchunk 1024 rows / 4 MiB", fillcolor="#4D4128"]; + decode [label="decode worker ×M\nplanned envelopes: descriptor + route attached,\nvalues resolved at planning\noracle PgPending resolve\nchunk 1024 rows / 4 MiB", fillcolor="#4D4128"]; } // ═════ batcher hub ═════ @@ -90,7 +93,7 @@ digraph emitter { ack [label="ack collector\nwait for every earlier commit\npublish latest durable position\nfeed manifest + source feedback", fillcolor="#4D3A28", shape=note]; // ═════ row path ═════ - reorder -> decode [color="#BF8C5F", penwidth=2, lhead=cluster_decode, label="DecodeJob\n(mpmc, bound 4M)"]; + plan -> decode [color="#BF8C5F", penwidth=2, lhead=cluster_decode, label="execute_plan:\nDecodeJob\n(mpmc, bound 4M)"]; decode -> enc [color="#BF8C5F", penwidth=2, lhead=cluster_batch, label="BatcherMsg::Rows\n(FIFO mpsc 256 —\nshared with FlushAll)"]; seal -> ins [color="#BF8C5F", penwidth=2, lhead=cluster_insert, label="InsertBatch\n(mpmc)"]; ins -> chrows [color="#BF8C5F", penwidth=2, label="one complete INSERT\nper sealed batch"]; diff --git a/architecture/emitter.svg b/architecture/emitter.svg index 2c2f7696..920379b0 100644 --- a/architecture/emitter.svg +++ b/architecture/emitter.svg @@ -4,21 +4,21 @@ - - + + emitter - -walshadow emitter — one ordered pipeline, ClickHouse or metrics only + +walshadow emitter — one ordered pipeline, ClickHouse or metrics only cluster_src - -reorder coordinator — single-threaded commit order  (emit/pipeline/reorder.rs, inner sink of QueueingRecordSink) + +reorder coordinator — single-threaded commit order  (emit/pipeline/reorder.rs, inner sink of QueueingRecordSink) cluster_decode - -decode pool ×M  (emit/pipeline/decode.rs) + +decode pool ×M  (emit/pipeline/decode.rs) cluster_batch @@ -27,65 +27,67 @@ cluster_insert - -inserter pool ×N  (emit/pipeline/inserter.rs) + +inserter pool ×N  (emit/pipeline/inserter.rs) cluster_ddl - -ordered control barriers — DDL / config / TOAST lifecycle + +ordered control barriers — DDL / config / TOAST lifecycle cluster_ch - -ClickHouse — main insert pool + DDL + TOAST store connections + +ClickHouse — main insert pool + DDL + TOAST store connections reorder - -transaction reorder -walk rows and changes in WAL order -empty commits and aborts still advance progress -schema and truncate changes wait for earlier rows + +transaction reorder +walk rows and changes in WAL order +empty commits and aborts still advance progress +schema and truncate changes wait for earlier rows - - -decode - -decode worker ×M -detoast: ChunkMap, then store as-of fetch -resolve_at_pooled; mapping -oracle PgPending + sampled validate -chunk 1024 rows / 4 MiB - - - -reorder->decode - - -DecodeJob -(mpmc, bound 4M) + + +plan + +transaction planner (planner.rs + plan_spool.rs) +plan first, execute after: detoast + route + +raw decode, side-effect-free; frozen route view +(whole-xact config granularity) +plan ≤1 MiB resident else .plan spool +[len|crc32c|body]*, seal frame; error = abandon, +nothing emitted + + + +reorder->plan + + +drain walk +(plan → seal → execute) - + fence - -barrier_fence -1. wait all seqs placed -2. batcher FlushAll (+ reply) -3. wait all seqs durable + +barrier_fence +1. wait all seqs placed +2. batcher FlushAll (+ reply) +3. wait all seqs durable - + reorder->fence - - -barrier xact: -per event / TRUNCATE + + +barrier xact: +per event / TRUNCATE - + toastput ToastResolver @@ -93,102 +95,121 @@ as-of fetch for pre-window values - + reorder->toastput - - -TOAST changes + + +TOAST changes - + ack - - - -ack collector -wait for every earlier commit -publish latest durable position -feed manifest + source feedback + + + +ack collector +wait for every earlier commit +publish latest durable position +feed manifest + source feedback - + reorder->ack - - -Register(seq, commit_lsn) + + +Register(seq, commit_lsn) + + + +decode + +decode worker ×M +planned envelopes: descriptor + route attached, +values resolved at planning +oracle PgPending resolve +chunk 1024 rows / 4 MiB + + + +plan->decode + + +execute_plan: +DecodeJob +(mpmc, bound 4M) - + cat - -ShadowCatalog::subscribe -SchemaEvent -Added / Changed / Dropped -(unbounded mpsc, rides xact buffer - as ordered_events) + +ShadowCatalog::subscribe +SchemaEvent +Added / Changed / Dropped +(unbounded mpsc, rides xact buffer + as ordered_events) - + cat->reorder - - -SchemaEvent -drains at commit + + +SchemaEvent +drains at commit - + enc - - - -TableEncoder per dest table -ColumnBuf slabs: Fixed / String / -NullableFixed / NullableString -column-major + synthetic -_lsn / _xid / _commit_ts / _is_deleted + + + +TableEncoder per dest table +ColumnBuf slabs: Fixed / String / +NullableFixed / NullableString +column-major + synthetic +_lsn / _xid / _commit_ts / _is_deleted - + decode->enc - - -BatcherMsg::Rows -(FIFO mpsc 256 — -shared with FlushAll) + + +BatcherMsg::Rows +(FIFO mpsc 256 — +shared with FlushAll) - + decode->toastput - - -fetch + + +fetch - + nulltail - - - -metrics-only tail -no ClickHouse connection -acknowledge rows immediately + + + +metrics-only tail +no ClickHouse connection +acknowledge rows immediately - + decode->nulltail - - -metrics only + + +metrics only - + decode->ack - - -Placed(seq, rows) + + +Placed(seq, rows) - + trip flush trigger @@ -197,258 +218,258 @@ 0 → 100 ms floor) · FlushAll - + enc->trip - - + + - + seal - -seal InsertBatch -owned slabs + per_seq row counts -FlushAll also bumps schema_epoch -(rebuild plans post-DDL) + +seal InsertBatch +owned slabs + per_seq row counts +FlushAll also bumps schema_epoch +(rebuild plans post-DDL) - + trip->seal - - + + - + ins - -inserter ×N — any idle takes any batch -TypeAst cache per (table, epoch) -BlockBuilder over owned slabs -send_query → send_data → EndOfStream -send_with_retry: reconnect + backoff, -insert_timeout 30 s; exhaustion = Fatal + +inserter ×N — any idle takes any batch +TypeAst cache per (table, epoch) +BlockBuilder over owned slabs +send_query → send_data → EndOfStream +send_with_retry: reconnect + backoff, +insert_timeout 30 s; exhaustion = Fatal - + seal->ins - - -InsertBatch -(mpmc) + + +InsertBatch +(mpmc) - + chrows - - -insert connections ×N -clickhouse-c-rs AsyncClient -ReplacingMergeTree(_lsn, _is_deleted) -Native rows + + +insert connections ×N +clickhouse-c-rs AsyncClient +ReplacingMergeTree(_lsn, _is_deleted) +Native rows - + ins->chrows - - -one complete INSERT -per sealed batch + + +one complete INSERT +per sealed batch - + ins->ack - - -Acked(per_seq) -only after EndOfStream + + +Acked(per_seq) +only after EndOfStream - + fence->trip - - -FlushAll + + +FlushAll - + apply - -apply ordered control -Catalog → DdlApplicator -Config → resolver republish -owner TRUNCATE → dest + toast mirror wipe -ToastBarrier → rewrite O−B -toast Dropped → durable retire enqueue + +apply ordered control +Catalog → DdlApplicator +Config → resolver republish +owner TRUNCATE → dest + toast mirror wipe +ToastBarrier → rewrite O−B +toast Dropped → durable retire enqueue - + fence->apply - - + + - + bridge - -type_bridge::map -RelAttr → ResolvedColumn -(reject type change, - pk strips Nullable) + +type_bridge::map +RelAttr → ResolvedColumn +(reject type change, + pk strips Nullable) - + bridge->apply - - -resolve types + + +resolve types - + chddl - - -DDL connection -clickhouse-c-rs AsyncClient -ALTER / CREATE / - DROP / TRUNCATE + + +DDL connection +clickhouse-c-rs AsyncClient +ALTER / CREATE / + DROP / TRUNCATE - + apply->chddl - - -DDL SQL -(own connection) + + +DDL SQL +(own connection) - + chtoast - - -TOAST store connection -pg_toast_<relid> mirrors -put / as-of fetch / truncate / O−B + + +TOAST store connection +pg_toast_<relid> mirrors +put / as-of fetch / truncate / O−B - + apply->chtoast - - -truncate / O−B + + +truncate / O−B - + ledger - - - -toast_retires.toml -saved mirror retirements + + + +toast_retires.toml +saved mirror retirements - + apply->ledger - - -toast DROP enqueue + + +toast DROP enqueue - + legend - - - -node fill — role - - - -reorder / decode pool / ClickHouse - - - -ShadowCatalog schema-event tx - - - -batcher / inserters / DdlApplicator - - - -ack collector / null tail / manifest side-channel - - -edge style - -━━ - -row path (Native rows, solid) - -┄┄ - -DDL path (own connection, dashed) - -┄┄ - -SchemaEvent off ShadowCatalog - -··· - -ack events / barrier waits - - -synthetic columns (every dest table) - -_lsn - -UInt64 — commit_lsn; ReplacingMergeTree dedup key - -_xid - -UInt32 — source xid; recovers xact boundary - -_commit_ts - -DateTime64(6, 'UTC'); shifted from PG epoch - -_is_deleted - -Bool — 1 on delete; ReplacingMergeTree is_deleted arg unless soft_delete - - -watermark rule - -commits may finish out of order; progress advances only after every earlier commit is durable. Failed batch stops process so restart can replay it. + + + +node fill — role + + + +reorder / decode pool / ClickHouse + + + +ShadowCatalog schema-event tx + + + +batcher / inserters / DdlApplicator + + + +ack collector / null tail / manifest side-channel + + +edge style + +━━ + +row path (Native rows, solid) + +┄┄ + +DDL path (own connection, dashed) + +┄┄ + +SchemaEvent off ShadowCatalog + +··· + +ack events / barrier waits + + +synthetic columns (every dest table) + +_lsn + +UInt64 — commit_lsn; ReplacingMergeTree dedup key + +_xid + +UInt32 — source xid; recovers xact boundary + +_commit_ts + +DateTime64(6, 'UTC'); shifted from PG epoch + +_is_deleted + +Bool — 1 on delete; ReplacingMergeTree is_deleted arg unless soft_delete + + +watermark rule + +commits may finish out of order; progress advances only after every earlier commit is durable. Failed batch stops process so restart can replay it. - + toastput->chtoast - - + + - + ledger->chtoast - - -retire after saved -restart point passes + + +retire after saved +restart point passes - + nulltail->ack - - -done + + +done - + ack->fence - - -wait_placed_through / -wait_through + + +wait_placed_through / +wait_through diff --git a/architecture/filter.dot b/architecture/filter.dot index 82b712cc..58f57dbb 100644 --- a/architecture/filter.dot +++ b/architecture/filter.dot @@ -5,8 +5,8 @@ // decision sub-table off to the side. // // regeneration spec: -// sources of truth: plans/filter.md · src/filter.rs · src/catalog_tracker.rs · src/rewrite.rs -// subsumes: plans/filter.md § "Filter contract" + "Rewrite over fork" +// sources of truth: plans/filter.md · src/filter/{engine,dirty_tree}.rs · src/filter/catalog_tracker.rs · src/filter/rewrite.rs +// subsumes: plans/filter.md § "Filter contract" + "Dirty tree" + "Rewrite over fork" // quality bar: // - track cluster doesn't push decide off main column // - noop / rewrite siblings rank-aligned so segbuf joins cleanly @@ -83,6 +83,12 @@ digraph filter { observe -> nodes [style=dashed, color="#CBA85E", label="tracker.observe(rec)"]; nodes -> iscat [style=dashed, color="#CBA85E", label="is_catalog(db, rel)"]; + // ════════ dirty tree (catalog-dirty xact families) ════════ + dirty [label="dirty tree (dirty_tree.rs)\ncatalog touch marks writing xid;\nlinks: inline toplevel xid +\nXLOG_XACT_ASSIGNMENT\nsubxact abort drops subtree only\ncommit/abort clears family", fillcolor="#4D4D28"]; + stamp [label="Record.defer_catalog_decode\nevery decoder-routed record of a\ndirty family → raw stash at the\ndecoder sink (see xact diagram)", fillcolor="#4D3850", shape=note]; + observe -> dirty [style=dashed, color="#CBA85E", label="catalog write\n→ mark xid dirty"]; + dirty -> stamp [style=dashed, color="#CBA85E", constraint=false, label="is_dirty(xid)"]; + // ════════ rewrite + emit ════════ subgraph cluster_rw { label="④ rewrite + emit"; style="rounded,filled"; color="#4c4641"; fillcolor="#34302c"; fontcolor="#ECE1D7"; diff --git a/architecture/filter.svg b/architecture/filter.svg index 90f26f34..2ccc2f31 100644 --- a/architecture/filter.svg +++ b/architecture/filter.svg @@ -4,21 +4,21 @@ - - + + filter - -walshadow filter — per-record keep/drop + byte-preserving NOOP rewrite + +walshadow filter — per-record keep/drop + byte-preserving NOOP rewrite cluster_in - -① ingress + +① ingress cluster_decide - -② Filter::decide  (per record, post-update tracker) + +② Filter::decide  (per record, post-update tracker) cluster_track @@ -33,53 +33,53 @@ chunk - -WalChunk -(source-order bytes, -page-framed) + +WalChunk +(source-order bytes, +page-framed) walker - -StreamingWalker -stitch records across pages, -yield (logical_bytes, - byte_ranges, page_magic) + +StreamingWalker +stitch records across pages, +yield (logical_bytes, + byte_ranges, page_magic) chunk->walker - - + + parse - -parse_record_from_bytes -wal-rus XLogRecord -blocks + main_data + +parse_record_from_bytes +wal-rus XLogRecord +blocks + main_data walker->parse - - + + observe - -tracker.observe(rec) -update catalog set -before classify + +tracker.observe(rec) +update catalog set +before classify parse->observe - - + + @@ -92,8 +92,8 @@ observe->classify - - + + @@ -111,9 +111,28 @@ observe->nodes - - -tracker.observe(rec) + + +tracker.observe(rec) + + + +dirty + +dirty tree (dirty_tree.rs) +catalog touch marks writing xid; +links: inline toplevel xid + +XLOG_XACT_ASSIGNMENT +subxact abort drops subtree only +commit/abort clears family + + + +observe->dirty + + +catalog write +→ mark xid dirty @@ -151,46 +170,46 @@ policy - - - - -rmgr keep policy - -HEAP / HEAP2 - -keep iff block ref ∈ catalog set - -BTREE - -keep iff block ref ∈ catalog set - -HASH / GIN / GIST / SPGIST / BRIN - -drop on user index - -SEQ / GENERIC / LOGICALMSG - -drop when no catalog ref - -RELMAP / XACT / CLOG / MULTIXACT / STANDBY - -always keep - -COMMIT_TS / REPL_ORIGIN / DBASE / TBLSPC / SMGR - -always keep - -XLOG - -CHECKPOINT / NEXTOID / PARAMETER_CHANGE keep + + + + +rmgr keep policy + +HEAP / HEAP2 + +keep iff block ref ∈ catalog set + +BTREE + +keep iff block ref ∈ catalog set + +HASH / GIN / GIST / SPGIST / BRIN + +drop on user index + +SEQ / GENERIC / LOGICALMSG + +drop when no catalog ref + +RELMAP / XACT / CLOG / MULTIXACT / STANDBY + +always keep + +COMMIT_TS / REPL_ORIGIN / DBASE / TBLSPC / SMGR + +always keep + +XLOG + +CHECKPOINT / NEXTOID / PARAMETER_CHANGE keep classify->policy - -rmgr → -class + +rmgr → +class @@ -233,7 +252,7 @@ no - + verbatim copy logical_bytes @@ -241,13 +260,13 @@ at byte_ranges - + keep->verbatim - + noop rewrite::noop_replace @@ -257,7 +276,7 @@ SHORT (≤257) | LONG - + drop->noop @@ -269,8 +288,26 @@ is_catalog(db, rel) + + +stamp + + + +Record.defer_catalog_decode +every decoder-routed record of a +dirty family → raw stash at the +decoder sink (see xact diagram) + + + +dirty->stamp + + +is_dirty(xid) + - + segbuf @@ -280,13 +317,13 @@ filtered_lsn == source_lsn - + verbatim->segbuf - + crc CRC32C recompute @@ -294,13 +331,13 @@ matches xlog.c:5169 - + noop->crc - + scatter scatter rewritten bytes @@ -309,19 +346,19 @@ (cross-seg: rewrite both) - + crc->scatter - + scatter->segbuf - + manifest @@ -332,13 +369,13 @@ + FilterStats delta - + segbuf->manifest - + legend diff --git a/architecture/internals.dot b/architecture/internals.dot index dfd1ed7a..cfc5c1f4 100644 --- a/architecture/internals.dot +++ b/architecture/internals.dot @@ -63,8 +63,8 @@ digraph internals { label="⑤ decode + xact buffer (worker task — gated on shadow apply)"; style="rounded,filled"; color="#4c4641"; fillcolor="#34302c"; fontcolor="#ECE1D7"; catgate [label="BufferingDecoderSink\nrelation_at(rfn, lsn)\nwait_for_replay(at_lsn)", fillcolor="#4D4128"]; decoder [label="heap_decoder\nINSERT / DELETE /\nUPDATE / HOT / TRUNCATE\nTier-1 fixed-width\nTier-2 length-prefixed\nReplica-identity matrix", fillcolor="#4D4128"]; - xactbuf [label="XactBuffer + spill v4\nHeap / Chunk / ToastDelete / Raw\nSMGR markers + commit stash\nSubxactTracker\nbudget = work_mem", fillcolor="#4D4128"]; - drain [label="transaction reorder\nwalk committed rows + changes in WAL order\nwait for earlier data before table changes", fillcolor="#4D4128"]; + xactbuf [label="XactBuffer + spill v6\nHeap / Chunk / ToastDelete / Raw / dict\ndirty tree + SMGR markers + commit stash\nSubxactTracker\nbudget = work_mem", fillcolor="#4D4128"]; + drain [label="transaction reorder\nplan (side-effect-free: detoast + route +\nraw decode) then execute sealed plan\nwait for earlier data before table changes", fillcolor="#4D4128"]; catgate -> decoder -> xactbuf -> drain; } qwrk -> catgate [lhead=cluster_decode]; @@ -72,7 +72,7 @@ digraph internals { // ════════ ⑥ emitter pipeline ════════ subgraph cluster_ch { label="⑥ emitter pipeline — shared path for ClickHouse and metrics-only runs"; style="rounded,filled"; color="#4c4641"; fillcolor="#34302c"; fontcolor="#ECE1D7"; - cdecode [label="decode pool ×M\ndetoast via ChunkMap / store as-of fetch\nresolve_at_pooled + mapping + oracle\nchunk 1024 rows / 4 MiB", fillcolor="#5D4628"]; + cdecode [label="decode pool ×M\nplanned envelopes (descriptor + route ride)\noracle PgPending resolve\nchunk 1024 rows / 4 MiB", fillcolor="#5D4628"]; tableenc [label="InsertBatcher\nTableEncoder per table\nNative columns +\n_lsn / _xid / _commit_ts / _is_deleted\nrow/byte budget + deadline seal", fillcolor="#5D4628"]; chwire [label="inserter pool ×N\nBlockBuilder over sealed batch\none complete INSERT each\nsend_with_retry + reconnect", fillcolor="#5D4628"]; nulltail [label="metrics-only tail\nno ClickHouse connection\nacknowledge immediately", fillcolor="#4D3A28", shape=note]; diff --git a/architecture/internals.svg b/architecture/internals.svg index 310db4bc..d0780885 100644 --- a/architecture/internals.svg +++ b/architecture/internals.svg @@ -4,808 +4,809 @@ - - + + internals - -walshadow internals — pipeline stages, taps & caches + +walshadow internals — pipeline stages, taps & caches cluster_ingress - -① ingress (main loop, tokio) + +① ingress (main loop, tokio) cluster_filter - -② filter pipeline (sync, record cadence) + +② filter pipeline (sync, record cadence) cluster_fanout - -③ CompositeRecordSink fan-out (pump task, sync — ordering matters) + +③ CompositeRecordSink fan-out (pump task, sync — ordering matters) cluster_queue - -④ QueueingRecordSink — pump ↔ decoder decoupling + +④ QueueingRecordSink — pump ↔ decoder decoupling cluster_decode - -⑤ decode + xact buffer (worker task — gated on shadow apply) + +⑤ decode + xact buffer (worker task — gated on shadow apply) cluster_ch - -⑥ emitter pipeline — shared path for ClickHouse and metrics-only runs + +⑥ emitter pipeline — shared path for ClickHouse and metrics-only runs cluster_catalog - -⑦ ShadowCatalog cache + SchemaEvent channel + +⑦ ShadowCatalog cache + SchemaEvent channel cluster_ddl - -⑦b DDL applicator (separate CH TCP) + +⑦b DDL applicator (separate CH TCP) cluster_walsender - -⑧ walsender server  (tokio task) + +⑧ walsender server  (tokio task) cluster_disk - -⑨ on-disk caches + +⑨ on-disk caches src - - -source PG + + +source PG feed - -SourceFeed::pump -wal-rus ReplicationConn -IDENTIFY_SYSTEM + -START_REPLICATION PHYSICAL + +SourceFeed::pump +wal-rus ReplicationConn +IDENTIFY_SYSTEM + +START_REPLICATION PHYSICAL src->feed - - + + shd - - -shadow PG -walreceiver + catalog -(pg_last_wal_replay_lsn) + + +shadow PG +walreceiver + catalog +(pg_last_wal_replay_lsn) stat - -rx 'r' standby status -flush_lsn / apply_lsn -(min across conns, - apply-lag metric) + +rx 'r' standby status +flush_lsn / apply_lsn +(min across conns, + apply-lag metric) shd->stat - - + + ch - - -ClickHouse + + +ClickHouse legend - - - -node fill — role - - - -source PG / ingress - - - -walshadow filter / queue (sync) - - - -walshadow output sinks (walsender, segment) - - - -walshadow decoder + xact buffer - - - -walshadow ShadowCatalog + schema-event tx - - - -walshadow walsender server - - - -walshadow CH pipeline tail + DdlApplicator - - - -on-disk artifact - - - -shadow Postgres - - -edge colour - -━━ - -source replication frame - -━━ - -walsender wire (hot path) - -━━ - -libpq catalog query / schema event - -━━ - -CH Native (rows + DDL SQL) - -··· - -ack + manifest durability - -┄┄ - -filesystem (restore_command) + + + +node fill — role + + + +source PG / ingress + + + +walshadow filter / queue (sync) + + + +walshadow output sinks (walsender, segment) + + + +walshadow decoder + xact buffer + + + +walshadow ShadowCatalog + schema-event tx + + + +walshadow walsender server + + + +walshadow CH pipeline tail + DdlApplicator + + + +on-disk artifact + + + +shadow Postgres + + +edge colour + +━━ + +source replication frame + +━━ + +walsender wire (hot path) + +━━ + +libpq catalog query / schema event + +━━ + +CH Native (rows + DDL SQL) + +··· + +ack + manifest durability + +┄┄ + +filesystem (restore_command) chunks - -WalChunk stream -(start_lsn, server_wal_end, - bytes) + +WalChunk stream +(start_lsn, server_wal_end, + bytes) feed->chunks - - + + walker - -StreamingWalker -page-state machine -record stitch across pages + +StreamingWalker +page-state machine +record stitch across pages chunks->walker - - + + decide - -Filter::decide -Keep catalog -Drop user-heap -Empty → reclass + +Filter::decide +Keep catalog +Drop user-heap +Empty → reclass walker->decide - - + + track - -CatalogTracker -relfilenode set -(relmap + pg_class writes) + +CatalogTracker +relfilenode set +(relmap + pg_class writes) inval - -invalidation_drain -(tokio task) -bump generation -on catalog write + +invalidation_drain +(tokio task) +bump generation +on catalog write track->inval - - + + decide->track - - - + + + rewrite - -rewrite::noop_replace -in-place at byte_ranges -+ CRC32C recompute + +rewrite::noop_replace +in-place at byte_ranges ++ CRC32C recompute decide->rewrite - - + + segbuf - - -16 MiB segment buffer -(rewritten image) + + +16 MiB segment buffer +(rewritten image) rewrite->segbuf - - + + bytesink - -❶ ShadowStreamSink -on_record_bytes -(stays on pump) + +❶ ShadowStreamSink +on_record_bytes +(stays on pump) segbuf->bytesink - - + + recsink - -❷ on_record → -QueueingRecordSink -(clones + enqueues, - returns immediately) + +❷ on_record → +QueueingRecordSink +(clones + enqueues, + returns immediately) segbuf->recsink - - + + segsink - -❸ DirSegmentSink -on_segment -(at 16 MiB boundary) + +❸ DirSegmentSink +on_segment +(at 16 MiB boundary) segbuf->segsink - - + + sendq - -per-conn send queue -'w' XLogData @ record -'k' keepalive on idle + +per-conn send queue +'w' XLogData @ record +'k' keepalive on idle bytesink->sendq - - + + qbuf - -pump-side batch -Vec<Record<'static>> -batch_size = 64 + +pump-side batch +Vec<Record<'static>> +batch_size = 64 recsink->qbuf - - + + outdir - - - -out/<seg> -16 MiB filtered -+ manifest.json + + + +out/<seg> +16 MiB filtered ++ manifest.json segsink->outdir - - + + qchan - -unbounded mpsc -Vec<Record> batches -soft cap → yield_now + +unbounded mpsc +Vec<Record> batches +soft cap → yield_now qbuf->qchan - - + + qwrk - -worker task -drains batches, -fires on_idle ticks + +worker task +drains batches, +fires on_idle ticks qchan->qwrk - - + + qerr - - - -shared err slot -surfaces back to pump + + + +shared err slot +surfaces back to pump qwrk->qerr - - + + catgate - -BufferingDecoderSink -relation_at(rfn, lsn) -wait_for_replay(at_lsn) + +BufferingDecoderSink +relation_at(rfn, lsn) +wait_for_replay(at_lsn) qwrk->catgate - - + + decoder - -heap_decoder -INSERT / DELETE / -UPDATE / HOT / TRUNCATE -Tier-1 fixed-width -Tier-2 length-prefixed -Replica-identity matrix + +heap_decoder +INSERT / DELETE / +UPDATE / HOT / TRUNCATE +Tier-1 fixed-width +Tier-2 length-prefixed +Replica-identity matrix catgate->decoder - - + + lru - -LRU 4096 -by_filenode / by_oid -+ generation counter + +LRU 4096 +by_filenode / by_oid ++ generation counter catgate->lru - - -lookup + + +lookup xactbuf - -XactBuffer + spill v4 -Heap / Chunk / ToastDelete / Raw -SMGR markers + commit stash -SubxactTracker -budget = work_mem + +XactBuffer + spill v6 +Heap / Chunk / ToastDelete / Raw / dict +dirty tree + SMGR markers + commit stash +SubxactTracker +budget = work_mem decoder->xactbuf - - + + drain - -transaction reorder -walk committed rows + changes in WAL order -wait for earlier data before table changes + +transaction reorder +plan (side-effect-free: detoast + route + +raw decode) then execute sealed plan +wait for earlier data before table changes xactbuf->drain - - + + spill - - - -{spill}/xid-<xid>-<lsn>.bin -append-only -[tag u8 | len u32 | body] + + + +{spill}/xid-<xid>-<lsn>.bin +append-only +[tag u8 | len u32 | body] xactbuf->spill - - -evict largest xact + + +evict largest xact cdecode - -decode pool ×M -detoast via ChunkMap / store as-of fetch -resolve_at_pooled + mapping + oracle -chunk 1024 rows / 4 MiB + +decode pool ×M +planned envelopes (descriptor + route ride) +oracle PgPending resolve +chunk 1024 rows / 4 MiB drain->cdecode - - -DecodeJob + + +DecodeJob toastio - -ToastResolver / CH mirrors -put births+tombstones before publish -fetch pre-window values -truncate / retire / rewrite O−B + +ToastResolver / CH mirrors +put births+tombstones before publish +fetch pre-window values +truncate / retire / rewrite O−B drain->toastio - - -new_rows + barriers + + +new_rows + barriers retires - - - -{spill}/toast_retires.toml -saved mirror retirements + + + +{spill}/toast_retires.toml +saved mirror retirements drain->retires - - -toast DROP + + +toast DROP tableenc - -InsertBatcher -TableEncoder per table -Native columns + -_lsn / _xid / _commit_ts / _is_deleted -row/byte budget + deadline seal + +InsertBatcher +TableEncoder per table +Native columns + +_lsn / _xid / _commit_ts / _is_deleted +row/byte budget + deadline seal cdecode->tableenc - - + + nulltail - - - -metrics-only tail -no ClickHouse connection -acknowledge immediately + + + +metrics-only tail +no ClickHouse connection +acknowledge immediately cdecode->nulltail - - -metrics only + + +metrics only chwire - -inserter pool ×N -BlockBuilder over sealed batch -one complete INSERT each -send_with_retry + reconnect + +inserter pool ×N +BlockBuilder over sealed batch +one complete INSERT each +send_with_retry + reconnect tableenc->chwire - - + + chwire->ch - - + + ackcol - - - -ack collector -publish latest position after -every earlier commit completes + + + +ack collector +publish latest position after +every earlier commit completes chwire->ackcol - - -Acked after -EndOfStream + + +Acked after +EndOfStream nulltail->ackcol - - -done + + +done manifest - - - -{spill}/manifest.toml -source identity + safe restart state -held behind open work + + + +{spill}/manifest.toml +source identity + safe restart state +held behind open work ackcol->manifest - - -status loop saves -durable progress + + +status loop saves +durable progress toastio->ch - - -pg_toast_<relid> + + +pg_toast_<relid> toastio->cdecode - - -as-of fetch + + +as-of fetch libpq - -tokio_postgres::Client -SELECT pg_class / -pg_attribute / pg_type + +tokio_postgres::Client +SELECT pg_class / +pg_attribute / pg_type lru->libpq - - -miss + + +miss evtx - -schema_event_tx -Added / Changed / Dropped -(unbounded mpsc) + +schema_event_tx +Added / Changed / Dropped +(unbounded mpsc) lru->evtx - - -diff vs prior + + +diff vs prior libpq->shd - - - -libpq SELECT + + + +libpq SELECT inval->lru - - + + evtx->xactbuf - - -stamp on xid + + +stamp on xid ddlapp - -DdlApplicator -(ch_ddl.rs) -CREATE / ADD COL / -RENAME / DROP TABLE + +DdlApplicator +(ch_ddl.rs) +CREATE / ADD COL / +RENAME / DROP TABLE evtx->ddlapp - - -drain at commit + + +drain at commit bridge - -type_bridge -RelAttr → CH type -(reject widen for now) + +type_bridge +RelAttr → CH type +(reject widen for now) bridge->ddlapp - - + + ddlapp->ch - - -DDL SQL -(inside reorder barrier, - own CH connection) + + +DDL SQL +(inside reorder barrier, + own CH connection) listener - -accept(unix / TCP) -IDENTIFY_SYSTEM + -START_REPLICATION + +accept(unix / TCP) +IDENTIFY_SYSTEM + +START_REPLICATION listener->sendq - - + + sendq->shd - - -hot wire (ms) + + +hot wire (ms) stat->catgate - - -apply_lsn → unblock gate + + +apply_lsn → unblock gate outdir->shd - - -restore_command + + +restore_command manifest->toastio - - -saved cleanup point + + +saved cleanup point retires->toastio - - -pending retirements + + +pending retirements diff --git a/architecture/oracle.dot b/architecture/oracle.dot index 00207c1e..3b095547 100644 --- a/architecture/oracle.dot +++ b/architecture/oracle.dot @@ -1,11 +1,11 @@ -// walshadow — differential decode oracle -// PgPending resolver path + Tier 3 sampling validator path, -// both 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 fronted by libpq into shadow PG. Extension +// surface (pgext/) shown as a side branch with absence-fallback +// semantics. // // regeneration spec: -// sources of truth: plans/oracle.md · pgext/ · src/ oracle-path module (grep src/ for oracle|valid|pending) -// subsumes: plans/oracle.md § differential decode flow + extension surface + --validate sampling +// 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 // 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 @@ -14,7 +14,7 @@ digraph oracle { rankdir=TB; compound=true; - graph [fontname="Helvetica", labelloc="t", label="walshadow oracle — differential decode bridge + 1-in-N validator", 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"]; @@ -24,15 +24,14 @@ digraph oracle { tier12 [label="ColumnValue::Tier 1/2\nfixed-width / varlena\nlocally rendered\n(no raw bytes kept)", fillcolor="#4D4128"]; tier3 [label="ColumnValue::Tier 3\nnumeric / inet / cidr /\ninterval (codecs.rs)\nlocal decode only\n(raw bytes discarded)", fillcolor="#4D4128"]; pend [label="ColumnValue::PgPending\n{ type_oid, raw }\njsonb / array / range /\ntsvector / domain / vendor", fillcolor="#5D3F40"]; + unsup [label="ColumnValue::Unsupported\n{ type_oid, raw }\nfail-closed backstop\nunless oracle resolves", fillcolor="#5D3F40"]; } // ════════ ② oracle decode path ════════ subgraph cluster_oracle { - label="② oracle path — ClickHouse 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 → resolve_pending\nswap to Text on Ok(Some)", fillcolor="#5D3F40"]; - sampler [label="Sampler::pick\nAtomicU64::fetch_add(Relaxed)\nrate == 0 short-circuits\nelse 1-in-N hit", fillcolor="#5D3F40", shape=parallelogram]; - validate [label="validate(type_oid, raw, local_text)\nPgPending only (raw present;\n local_text empty) —\nprobe = extension call succeeds\n+ returns text", fillcolor="#5D3F40"]; - stats [label="OracleStats\nresolved / fallback_raw /\nprobes / matches /\nmismatches / errors", fillcolor="#5D3F40", shape=note]; + 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]; } // ════════ ③ libpq client (own pool, not catalog's) ════════ @@ -56,27 +55,19 @@ digraph oracle { subgraph cluster_emit { label="⑥ downstream — emit-time disposition"; style="rounded,filled"; color="#4c4641"; fillcolor="#34302c"; fontcolor="#ECE1D7"; emit_text [label="ColumnValue::Text\n→ CH String\n(encode_value)", fillcolor="#5D4628"]; - emit_raw [label="PgPending raw bytes\n→ append_string_bytes(raw)\nunsupported_values++\nplaceholder ", fillcolor="#5D4628"]; - alarm [label="mismatch alarm\nlog + stats.mismatches++\nrow STILL ships\n(watchdog, not gate)", fillcolor="#5D4628", shape=note]; + emit_raw [label="PgPending raw bytes\n→ append_string_bytes(raw)\nbest-effort verbatim body", fillcolor="#5D4628"]; + emit_fail [label="Unsupported unresolved\n→ EmitterError::\nUnsupportedValue (fatal)", fillcolor="#5D4628", shape=note]; } - // ════════ ⑦ CLI gate ════════ - cli [label="walshadow-stream\n--validate \n(0 = off,\n default)", fillcolor="#3D3D54", shape=parallelogram]; - // ─── main flow edges (LR spine, high weight) ─── pend -> resolve [color="#A1A9CC", penwidth=2, label="always", weight=10]; - resolve -> client [color="#CBA85E", penwidth=2, label="resolve_pending\nper PgPending col", 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]; - // ─── validator branch (parallel rail) ─── - pend -> sampler [color="#b380b0", style=dashed]; - tier3 -> sampler [color="#b380b0", style=dotted, label="skipped\n(raw bytes\n discarded)"]; - tier12 -> sampler [color="#b380b0", style=dotted, label="skipped\n(no raw\n bytes)"]; - sampler -> validate [color="#b380b0", label="1-in-N hit"]; - validate -> client [color="#CBA85E", label="probe SQL"]; - - // ─── CLI rate annotation (side input to sampler) ─── - cli -> sampler [color="#b380b0", style=dotted, label="rate N"]; + // ─── 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]; @@ -86,35 +77,30 @@ digraph oracle { artifact -> ext [style=dashed, color="#6E6963", lhead=cluster_shadow, ltail=cluster_pgext, label="install:\n$libdir +\nextension dir;\nCREATE EXTENSION\nwalshadow"]; // ─── stats fan-in (off-axis, constraint=false) ─── - resolve -> stats [style=dashed, color="#CBA85E", constraint=false, label="resolved++ /\nfallback_raw++"]; - validate -> stats [style=dashed, color="#CBA85E", constraint=false, label="probes++ /\nmatches /\nmismatches++"]; + resolve -> stats [style=dashed, color="#CBA85E", constraint=false, label="resolved++ /\nfallback_raw++ /\nerrors++"]; // ─── emit disposition (off-axis fan-out from oracle) ─── - resolve -> emit_text [color="#BF8C5F", penwidth=2, constraint=false, label="Ok(Some)\nPgPending\n→ Text"]; - resolve -> emit_raw [color="#BF8C5F", style=dashed, constraint=false, label="Ok(None)\next absent /\nNULL / error"]; - validate -> alarm [color="#BF8C5F", style=dashed, constraint=false, label="probe error /\ntext mismatch"]; + 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)"]; // ─── Legend ─── legend [shape=plaintext, label=< - - + - + - - + - - - - + +
node fill
CLI gate
decoder ColumnValue
decoder ColumnValue
oracle (src/ops/oracle.rs)
libpq client (own pool)
shadow PG / extension
pgext/ build artifact
emit sink / alarm
emit sink
edge colour
━━PgPending → resolver (always)
┄┄sampler gate (--validate N)
━━pending → resolver (always)
━━libpq SELECT (MAIN wire)
━━emit-time disposition
┄┄pgext install (filesystem)
trigger sources
PgPending resolvealways — once per col
PgPending probe--validate N, sampler-gated
Tier 1/2/3not validated (raw bytes not retained)
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.
diff --git a/architecture/oracle.svg b/architecture/oracle.svg index 777ae008..85ce3a5c 100644 --- a/architecture/oracle.svg +++ b/architecture/oracle.svg @@ -4,424 +4,359 @@ - - + + oracle - -walshadow oracle — differential decode bridge + 1-in-N validator + +walshadow oracle — PgPending decode bridge cluster_decoder - -① decoder — ColumnValue (heap_decoder.rs) + +① decoder — ColumnValue (heap_decoder.rs) cluster_oracle - -② oracle path — ClickHouse decode workers call directly; metrics-only runs skip it + +② oracle path — post-plan, decode workers call directly; 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) - - -sampler - -Sampler::pick -AtomicU64::fetch_add(Relaxed) -rate == 0 short-circuits -else 1-in-N hit - - - -tier12->sampler - - -skipped -(no raw - bytes) + + +emit_text + +ColumnValue::Text +→ CH String +(encode_value) + + + +tier12->emit_text + + +local render tier3 - -ColumnValue::Tier 3 -numeric / inet / cidr / -interval (codecs.rs) -local decode only -(raw bytes discarded) - - - -tier3->sampler - - -skipped -(raw bytes - discarded) + +ColumnValue::Tier 3 +numeric / inet / cidr / +interval (codecs.rs) +local decode only +(raw bytes discarded) + + + +tier3->emit_text + + +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 → resolve_pending -swap to Text on Ok(Some) + +resolve_pending_tuple +walk columns, +PgPending | Unsupported +→ resolve_pending +swap to Text on Ok(Some) +best effort: shadow may lag +row's catalog interval pend->resolve - - -always + + +always - - -pend->sampler - - + + +unsup + +ColumnValue::Unsupported +{ type_oid, raw } +fail-closed backstop +unless oracle resolves + + + +unsup->resolve + + +always - + stats - - - -OracleStats -resolved / fallback_raw / -probes / matches / -mismatches / errors + + + +OracleStats +resolved / fallback_raw / +errors - + resolve->stats - - -resolved++ / -fallback_raw++ + + +resolved++ / +fallback_raw++ / +errors++ - + client - -Oracle::client -tokio_postgres::Client -SELECT walshadow_decode_disk( -  $1::oid, $2::bytea) -reconnect-once on is_closed + +Oracle::client +tokio_postgres::Client +SELECT walshadow_decode_disk( +  $1::oid, $2::bytea) +reconnect-once on is_closed - + resolve->client - - -resolve_pending -per PgPending col - - - -emit_text - -ColumnValue::Text -→ CH String -(encode_value) + + +resolve_pending +per pending col - + resolve->emit_text - - -Ok(Some) -PgPending -→ Text + + +Ok(Some) +pending +→ Text - + emit_raw - -PgPending raw bytes -→ append_string_bytes(raw) -unsupported_values++ -placeholder <oid:N> + +PgPending raw bytes +→ append_string_bytes(raw) +best-effort verbatim body - + resolve->emit_raw - - -Ok(None) -ext absent / -NULL / error - - - -validate - -validate(type_oid, raw, local_text) -PgPending only (raw present; - local_text empty) — -probe = extension call succeeds -+ returns text - - - -sampler->validate - - -1-in-N hit - - - -validate->stats - - -probes++ / -matches / -mismatches++ - - - -validate->client - - -probe SQL - - - -alarm - - - -mismatch alarm -log + stats.mismatches++ -row STILL ships -(watchdog, not gate) - - - -validate->alarm - - -probe error / -text mismatch + + +Ok(None) +ext absent / +NULL / error +(PgPending) + + + +emit_fail + + + +Unsupported unresolved +→ EmitterError:: +UnsupportedValue (fatal) + + + +resolve->emit_fail + + +Ok(None) +(Unsupported) - + 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) + +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) - + client->ext - - - -libpq SELECT -tokio_postgres pool -(separate from - ShadowCatalog) + + + +libpq SELECT +tokio_postgres pool +(separate from + ShadowCatalog) - + probe - - - -probe_extension -SELECT EXISTS -(pg_proc.proname = - 'walshadow_decode_disk') -at connect + reconnect + + + +probe_extension +SELECT EXISTS +(pg_proc.proname = + 'walshadow_decode_disk') +at connect + reconnect - + client->probe - - -connect / -reconnect: -probe pg_proc + + +connect / +reconnect: +probe pg_proc - + lc - - - -pinned locale -lc_numeric / lc_time = C -(shadow bootstrap) + + + +pinned locale +lc_numeric / lc_time = C +(shadow bootstrap) - + ext->lc - - + + - + legend - - - -node fill - - - -CLI gate - - - -decoder ColumnValue - - - -oracle (src/ops/oracle.rs) - - - -libpq client (own pool) - - - -shadow PG / extension - - - -pgext/ build artifact - - - -emit sink / alarm - - -edge colour - -━━ - -PgPending → resolver (always) - -┄┄ - -sampler gate (--validate N) - -━━ - -libpq SELECT (MAIN wire) - -━━ - -emit-time disposition - -┄┄ - -pgext install (filesystem) - - -trigger sources - -PgPending resolve - -always — once per col - -PgPending probe - ---validate N, sampler-gated - -Tier 1/2/3 - -not validated (raw bytes not retained) - - -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) + + + +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. - + artifact - - - -walshadow.so -walshadow.control -walshadow--0.1.sql -CREATE FUNCTION -  STRICT IMMUTABLE + + + +walshadow.so +walshadow.control +walshadow--0.1.sql +CREATE FUNCTION +  STRICT IMMUTABLE - -artifact->ext - - -install: -$libdir + -extension dir; -CREATE EXTENSION -walshadow - - - -cli - -walshadow-stream ---validate <N> -(0 = off, - default) - - -cli->sampler - - -rate N +artifact->ext + + +install: +$libdir + +extension dir; +CREATE EXTENSION +walshadow diff --git a/architecture/xact.dot b/architecture/xact.dot index ff1527e2..6d16d9d8 100644 --- a/architecture/xact.dot +++ b/architecture/xact.dot @@ -23,7 +23,7 @@ digraph xact { // (feeds subt + abort + merge, all aligned beneath it) { rank=same; catevt [label="ShadowCatalog\nschema_event_tx\nAdded / Changed / Dropped", fillcolor="#4D4D28"]; - decoder [label="BufferingDecoderSink\nheap + TOAST birth/death reshape\nSMGR marker + invisible-record stash\nstamps source_lsn, xid", fillcolor="#4D4128"]; + decoder [label="BufferingDecoderSink\nheap + TOAST birth/death reshape\nraw stash: dirty tree · SMGR marker ·\nspanned-lookup miss (NotCovered /\nDropped / Ambiguous)\nstamps source_lsn, xid", fillcolor="#4D4128"]; xaclog [label="XLOG_XACT_*\nASSIGNMENT 0x50 (hint)\nCOMMIT 0x00 / _PREPARED 0x30\nABORT 0x20 / _PREPARED 0x40", fillcolor="#3D3D54"]; } @@ -60,7 +60,7 @@ digraph xact { // ════════ Rank 2: evict + spill (sidecar pair under buf) ════════ { rank=same; evict [label="maybe_evict\nbytes_in_memory > xact_buffer_max (64 MiB)\npick largest in-mem xact\n(mirrors PG ReorderBufferLargestTXN)\n→ evict_xact: lazy-open SpillWriter,\ndrain in_mem → write(entry), zero bytes\nDrainEntry events stay in memory", fillcolor="#4D3A28"]; - spill [label="{spill}/xid-{xid:010}-{first_lsn:016X}.bin\n[\"WS\" magic | u16 ver=4]\n[tag u8 | u32 LE inner_len | body]*\n0 Heap · 1 Chunk · 2 ToastDelete · 3 Raw\nappend-only, fsync on finish", fillcolor="#4D3850", shape=note]; + spill [label="{spill}/xid-{xid:010}-{first_lsn:016X}.bin\n[\"WS\" magic | u16 ver=6]\n[tag u8 | u32 LE inner_len | body]*\n0 Heap · 1 Chunk · 2 ToastDelete ·\n3 Raw · 4 descriptor dict\nappend-only, fsync on finish", fillcolor="#4D3850", shape=note]; } buf -> evict [label="after every absorb\nwhile over budget"]; evict -> spill [color="#6E6963", style=dashed, label="SpillWriter::write"]; @@ -70,7 +70,7 @@ digraph xact { subgraph cluster_drain { label="commit drain — merge transaction family, preserve WAL order"; style="rounded,filled"; color="#4c4641"; fillcolor="#34302c"; fontcolor="#ECE1D7"; - merge [label="pull (top + subxids) from inflight\nper xid: SpillReader + in_mem + events\n→ source_lsn k-way merge\nChunk → ChunkMap + ToastRow birth\nToastDelete → tombstone row\nRaw → commit-resolved toast op\nTIE: control event BEFORE heap", fillcolor="#4D4128"]; + merge [label="pull (top + subxids) from inflight\nper xid: SpillReader + in_mem + events\n→ source_lsn k-way merge\nChunk → ChunkMap + ToastRow birth\nToastDelete → tombstone row\nRaw → resolve_stash verdict at commit next_lsn:\n Toast → chunk decode · Ordinary → row fanout\n Ambiguous → fatal · tombstoned/uncovered → discard\nTIE: control event BEFORE heap", fillcolor="#4D4128"]; dispatch [label="bounded committed batches\none shared walk keeps rows, TOAST,\nand table changes in WAL order", fillcolor="#4D4128"]; diff --git a/architecture/xact.svg b/architecture/xact.svg index 3e1ef74e..1267f5b6 100644 --- a/architecture/xact.svg +++ b/architecture/xact.svg @@ -4,391 +4,396 @@ - - + + xact - -walshadow — XactBuffer + spill + commit drain + +walshadow — XactBuffer + spill + commit drain cluster_drain - -commit drain — merge transaction family, preserve WAL order + +commit drain — merge transaction family, preserve WAL order catevt - -ShadowCatalog -schema_event_tx -Added / Changed / Dropped + +ShadowCatalog +schema_event_tx +Added / Changed / Dropped buf - -XactBuffer -inflight: HashMap<xid, XactState> -markers · pending stash · unfinished commits -bytes_in_memory · drain_resident · SpillStore -XactState (per xid): -first_lsn · in_mem: Vec<SpillEntry> · in_mem_bytes -spill: Option<SpillWriter> · spill_bytes -events: Vec<(lsn, DrainEntry)> · stash_rfns -SpillEntry = Heap | Chunk | ToastDelete | Raw + +XactBuffer +inflight: HashMap<xid, XactState> +markers · pending stash · unfinished commits +bytes_in_memory · drain_resident · SpillStore +XactState (per xid): +first_lsn · in_mem: Vec<SpillEntry> · in_mem_bytes +spill: Option<SpillWriter> · spill_bytes +events: Vec<(lsn, DrainEntry)> · stash_rfns +SpillEntry = Heap | Chunk | ToastDelete | Raw catevt->buf - - -on_schema_event -(xid, source_lsn, ev) + + +on_schema_event +(xid, source_lsn, ev) decoder - -BufferingDecoderSink -heap + TOAST birth/death reshape -SMGR marker + invisible-record stash -stamps source_lsn, xid + +BufferingDecoderSink +heap + TOAST birth/death reshape +raw stash: dirty tree · SMGR marker · +spanned-lookup miss (NotCovered / +Dropped / Ambiguous) +stamps source_lsn, xid decoder->buf - - -absorb Heap / Chunk / -ToastDelete / Raw + + +absorb Heap / Chunk / +ToastDelete / Raw xaclog - -XLOG_XACT_* -ASSIGNMENT 0x50 (hint) -COMMIT 0x00 / _PREPARED 0x30 -ABORT  0x20 / _PREPARED  0x40 + +XLOG_XACT_* +ASSIGNMENT 0x50 (hint) +COMMIT 0x00 / _PREPARED 0x30 +ABORT  0x20 / _PREPARED  0x40 subt - -SubxactTracker -parent: HashMap<xid, top> -children: HashMap<top, Vec<xid>> + +SubxactTracker +parent: HashMap<xid, top> +children: HashMap<top, Vec<xid>> xaclog->subt - - -ASSIGNMENT 0x50 -add_subxact(top, subs) + + +ASSIGNMENT 0x50 +add_subxact(top, subs) abort - -abort -drop transaction + children -unlink spill files -advance consumed position + +abort +drop transaction + children +unlink spill files +advance consumed position xaclog->abort - - -ABORT / _PREPARED + + +ABORT / _PREPARED merge - -pull (top + subxids) from inflight -per xid: SpillReader + in_mem + events -→ source_lsn k-way merge -Chunk → ChunkMap + ToastRow birth -ToastDelete → tombstone row -Raw → commit-resolved toast op -TIE: control event BEFORE heap + +pull (top + subxids) from inflight +per xid: SpillReader + in_mem + events +→ source_lsn k-way merge +Chunk → ChunkMap + ToastRow birth +ToastDelete → tombstone row +Raw → resolve_stash verdict at commit next_lsn: +  Toast → chunk decode · Ordinary → row fanout +  Ambiguous → fatal · tombstoned/uncovered → discard +TIE: control event BEFORE heap xaclog->merge - - -COMMIT / _PREPARED -parse_xact_payload + + +COMMIT / _PREPARED +parse_xact_payload idle - -idle progress -advance only with no -open transaction + +idle progress +advance only with no +open transaction buf->idle - - -xacts_active == 0 + + +xacts_active == 0 evict - -maybe_evict -bytes_in_memory > xact_buffer_max (64 MiB) -pick largest in-mem xact -(mirrors PG ReorderBufferLargestTXN) -→ evict_xact: lazy-open SpillWriter, -drain in_mem → write(entry), zero bytes -DrainEntry events stay in memory + +maybe_evict +bytes_in_memory > xact_buffer_max (64 MiB) +pick largest in-mem xact +(mirrors PG ReorderBufferLargestTXN) +→ evict_xact: lazy-open SpillWriter, +drain in_mem → write(entry), zero bytes +DrainEntry events stay in memory buf->evict - - -after every absorb -while over budget + + +after every absorb +while over budget safe - -ack collector + transaction state -choose safe restart point -stay behind open or unfinished work + +ack collector + transaction state +choose safe restart point +stay behind open or unfinished work buf->safe - - -open transactions + + +open transactions subt->merge - - -(top + subxids) -authoritative on COMMIT + + +(top + subxids) +authoritative on COMMIT spill - - - -{spill}/xid-{xid:010}-{first_lsn:016X}.bin -["WS" magic | u16 ver=4] -[tag u8 | u32 LE inner_len | body]* -0 Heap · 1 Chunk · 2 ToastDelete · 3 Raw -append-only, fsync on finish + + + +{spill}/xid-{xid:010}-{first_lsn:016X}.bin +["WS" magic | u16 ver=6] +[tag u8 | u32 LE inner_len | body]* +0 Heap · 1 Chunk · 2 ToastDelete · +3 Raw · 4 descriptor dict +append-only, fsync on finish abort->spill - - -unlink + + +unlink abort->safe - - + + idle->safe - - + + evict->spill - - -SpillWriter::write + + +SpillWriter::write spill->merge - - -SpillReader::next -then unlink + + +SpillReader::next +then unlink dispatch - -bounded committed batches -one shared walk keeps rows, TOAST, -and table changes in WAL order + +bounded committed batches +one shared walk keeps rows, TOAST, +and table changes in WAL order merge->dispatch - - + + detoast - -resolve large values -current transaction first, -mirror history second + +resolve large values +current transaction first, +mirror history second dispatch->detoast - - -rows + + +rows consumer - -shared consumers -live pipeline: ClickHouse or metrics only -backup replay: same ordered walk + +shared consumers +live pipeline: ClickHouse or metrics only +backup replay: same ordered walk dispatch->consumer - - -table changes + + +table changes store - - -TOAST mirror -save changes before publishing commit -interleave table wipes and rewrites + + +TOAST mirror +save changes before publishing commit +interleave table wipes and rewrites dispatch->store - - -TOAST changes + + +TOAST changes dispatch->safe - - -drained + + +drained detoast->consumer - - -decoded rows + + +decoded rows consumer->safe - - -downstream completed + + +downstream completed manifest - - - -manifest.toml -saved restart state + + + +manifest.toml +saved restart state safe->manifest - - -status update + + +status update legend - - - -node fill — role - - - -source PG / XLOG_XACT records - - - -eviction policy (sync, after absorb) - - - -XactBuffer / decoder / drain - - - -ShadowCatalog schema_event_tx - - - -downstream consumer (pipeline / gap replay) - - - -on-disk artifact (spill / manifest) - - -edge colour - -━━ - -buffer-internal control / data - -┄┄ - -schema event (lsn-stamped) - -━━ - -walk steps to pipeline / replay consumers - -┄┄ - -spill file IO (write / read / unlink) - -··· - -ack + manifest durability - - -k-way merge — ordering rules - -heads sorted ASC by source_lsn - -tie: catalog event BEFORE tuple (PG writes pg_class first) - -heaps detoast post-merge; chunk maps resolve rows; ToastRows persist separately - -saved restart point never passes open or unfinished transaction + + + +node fill — role + + + +source PG / XLOG_XACT records + + + +eviction policy (sync, after absorb) + + + +XactBuffer / decoder / drain + + + +ShadowCatalog schema_event_tx + + + +downstream consumer (pipeline / gap replay) + + + +on-disk artifact (spill / manifest) + + +edge colour + +━━ + +buffer-internal control / data + +┄┄ + +schema event (lsn-stamped) + +━━ + +walk steps to pipeline / replay consumers + +┄┄ + +spill file IO (write / read / unlink) + +··· + +ack + manifest durability + + +k-way merge — ordering rules + +heads sorted ASC by source_lsn + +tie: catalog event BEFORE tuple (PG writes pg_class first) + +heaps detoast post-merge; chunk maps resolve rows; ToastRows persist separately + +saved restart point never passes open or unfinished transaction diff --git a/plans/GLOSSARY.md b/plans/GLOSSARY.md index 0066654d..43ecb546 100644 --- a/plans/GLOSSARY.md +++ b/plans/GLOSSARY.md @@ -42,7 +42,9 @@ DDL / TRUNCATE / config apply: wait placed, `FlushAll`, wait durable, then apply. Global, acceptable because barriers run at DDL rate ([emitter.md](emitter.md)) -**baseline ledger** — see prev_known +**baseline ledger** — the descriptor log's per-oid predecessor chain; +decides SchemaEvent `Added` vs `Changed`, durable across restarts +([desc_log.md](desc_log.md)) **body spool** — per-drain `toastbody-*` file of raw concatenated chunk bodies past `toast_body_mem_max`; resolution map and mirror rows @@ -105,6 +107,15 @@ cluster-sized pass per mode inside fixed 1 s window variants, Tier 2 varlena (`Bytea`/`Text`/`Json`), `ExternalToast`, `PgPending`, `Null`, `Unsupported` ([decoder.md](decoder.md)) +**commit-time stash** — raw decode inputs (`SpillEntry::Raw`) of records +undecodable at record time (rewrite generation, same-xact +CREATE/TRUNCATE + INSERT, catalog-dirty xact family), admitted by +generation marker / dirty tree / lookup miss, resolved at commit +against the descriptor log at the commit's `next_lsn` and decoded in +the drain merge; toast and ordinary heaps decode under their commit +verdict, ambiguous fails closed, tombstoned/uncovered discard counted +([xact.md](xact.md), [TOAST.md](TOAST.md)) + **config precedence** — three-layer merge, highest wins: CLI > PG row > TOML; snapshot rebuilt whole per republish so it never tears ([config.md](config.md)) @@ -256,11 +267,10 @@ resend the still-owned batch and `_lsn` dedup absorbs duplicates but not installed refuses boot, keeping overlay opt-in explicit ([config.md](config.md)) -**invalidation_epoch** — shared `AtomicU64` bumped on relmap / pg_class -writes and shape-changing config events; ShadowCatalog loads it per -lookup, `pg_class_delete_epoch` is the narrower DROP-only sibling -throttling `sweep_dropped` ([filter.md](filter.md), -[shadow.md](shadow.md)) +**interval lookup** — `descriptor_at(rfn, L)` / `descriptor_by_oid_at`: +binary search of a key's version chain for the last entry with +`valid_from <= L`; wait-free, replaces cache + invalidation machinery +([desc_log.md](desc_log.md)) **keep-fraction** — share of records filter keeps: ~0.04% steady OLTP, 8%+ in DDL-heavy windows ([filter.md](filter.md)) @@ -323,10 +333,10 @@ 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** — differential decode oracle: shadow re-decodes on-disk bytes -via `walshadow_decode_disk` (same `typoutput` PG would call), diffed -against local decode. `--validate N` samples 1-in-N; mismatch is a -watchdog signal, row still ships ([oracle.md](oracle.md)) +**oracle** — PgPending resolver: shadow decodes on-disk bytes +via `walshadow_decode_disk` (same `typoutput` PG would call) into text +post-plan; best-effort, unresolved values ship raw bytes +([oracle.md](oracle.md)) **ordered_events** — `DrainedBatch` catalog/config/toast-barrier positions interleaved with heaps and TOAST-row cursors; pipeline walks them as @@ -379,11 +389,10 @@ usable replica identity, slot existence) aggregated into one report so multiple findings surface at once; `--skip-preflight` for drills ([ops.md](ops.md)) -**prev_known / baseline ledger** — last source shape CH and source -agreed on, per oid; decides SchemaEvent `Added` vs `Changed`. -`seed_baseline` warms it for pinned rels at boot so a cold cache never -mis-branches post-boot ALTER ([shadow.md](shadow.md), -[emitter.md](emitter.md)) +**predecessor (log)** — the per-oid entry preceding a batch in the +descriptor log's history order; capture diffs against it for events, +boot replay uses `predecessor_before` so re-derived events ignore the +loaded head ([desc_log.md](desc_log.md)) **QueueingRecordSink** — unbounded mpsc decoupling pump from decoder's replay wait; `soft_cap` yields past threshold, hard bound deliberately @@ -436,9 +445,11 @@ mirror is never truncated ([TOAST.md](TOAST.md)) below `replay_lsn - retention_bytes`; `--retention-bytes 0` disables ([ops.md](ops.md)) -**rfn** — RelFileLocator `(db_node, rel_node)`; relation identity in WAL -block refs. Unique only within a database, hence foreign-DB skip -([decoder.md](decoder.md), [shadow.md](shadow.md)) +**rfn** — RelFileLocator `(spc_node, db_node, rel_node)`; physical +relation identity in WAL block refs. Relfilenumbers are unique only per +database of one tablespace, so all three components are load-bearing +([future/TABLESPACES.md](future/TABLESPACES.md) §0); foreign-DB locators +skip ([decoder.md](decoder.md), [shadow.md](shadow.md)) **rfn flip** — bootstrap drain boundary between one relfilenode's rows and the next; each flip closes one synthetic ack seq (bootstrap has no @@ -481,9 +492,9 @@ never hosts user-heap data, never written locally (local write would diverge offset-exact pages and PANIC on replay) ([overview.md](overview.md), [shadow.md](shadow.md)) -**ShadowCatalog** — async libpq cache over shadow's unix socket: -replay-gated `relation_at`, generation-checked descriptor cache, -SchemaEvent channel, auto-reconnect with transient retry +**ShadowCatalog** — async libpq client over shadow's unix socket: +batched descriptor fetches for capture, name-keyed resolution for +opt-in/backfill, replay gate, auto-reconnect with transient retry ([shadow.md](shadow.md)) **shadow-zero carve-out** — `shadow_replay_lsn == 0` treated as "no @@ -553,13 +564,6 @@ window can't orphan the wipe: pipeline standup flushes entries whose drop resume never replays; counted `toast_mirror_retires` at execution ([TOAST.md](TOAST.md)) -**toast stash** — raw decode inputs (`SpillEntry::Raw`) of records -whose filenode is MVCC-invisible at record time (rewrite generation, -same-xact CREATE/TRUNCATE + INSERT), admitted by generation marker, -resolved at commit via `relation_at(rfn, commit_lsn)` and decoded in -the drain merge; unresolvable → discarded counted, ordinary heap → -fenced skipped ([TOAST.md](TOAST.md)) - **toast tombstone** — store row `(blkno, offnum, 0, 0, '', delete_record_lsn, 1)` a toast DELETE lands at its TID; supersedes the data row so RMT merge reclaims dead chunks with no walshadow GC, fetch diff --git a/plans/INDEX.md b/plans/INDEX.md index e9491dbc..36f8ac20 100644 --- a/plans/INDEX.md +++ b/plans/INDEX.md @@ -10,26 +10,29 @@ Cross-doc terminology is collected in [GLOSSARY.md](GLOSSARY.md) - [overview.md](overview.md) — system shape, supported PG versions, filter contract, ordering invariants, acceptance gates - [filter.md](filter.md) — WAL filter, CRC rewrite, catalog tracker, - rmgr-level keep/drop, NOOP-over-fork rationale + dirty tree, rmgr-level keep/drop, NOOP-over-fork rationale - [source.md](source.md) — START_REPLICATION PHYSICAL pump, `WalStream`, `StreamingWalker`, fan-out sinks, `QueueingRecordSink`, `DecoderSink`, walshadow walsender server - [shadow.md](shadow.md) — shadow PG lifecycle, `ShadowCatalog` async - libpq cache, `RelDescriptor`, `SchemaEvent` channel, reconnect - resilience + libpq client, `RelDescriptor`, reconnect resilience +- [desc_log.md](desc_log.md) — durable descriptor log: boundary + capture, interval lookups, ambiguity intervals, replay-from-log, + seed + coverage horizon, GC against the resolved floor - [decoder.md](decoder.md) — heap-tuple decoder, Tier 1/2 codec matrix, FPI decompression, `main_data` parsers, `pg_class_decoder`, read-time defaults - [xact.md](xact.md) — `XactBuffer`, `SubxactTracker`, TOAST - reassembly, local-disk spill + body spool, `DrainEntry` ordering + reassembly, commit-time stash + raw decode, local-disk spill + body + spool, `DrainEntry` ordering - [TOAST.md](TOAST.md) — TID-keyed `pg_toast_` CH mirror (`disabled`/`clickhouse`), delete tombstones + RMT-merge reclaim, as-of fetch, superseded-fill miss policy, bootstrap tap + defer-resolve; deferred R1 JOIN mode, streaming reassembly - [emitter.md](emitter.md) — parallel decode+insert pipeline - (reorder → decode ×M → batcher → inserter ×N → ack watermark), - memory budget, `type_bridge`, synthetic columns, `DdlApplicator`, - barrier fence + (reorder plan → execute → decode ×M → batcher → inserter ×N → ack + watermark), transaction planner + plan spool, memory budget, + `type_bridge`, synthetic columns, `DdlApplicator`, barrier fence - [bootstrap.md](bootstrap.md) — greenfield BASE_BACKUP, `BackupSource` / `BackupSink` traits, `MultiplexSink`, `PageWalkSink` 2A decoder, shared insert tail, restart source fallback contract @@ -40,8 +43,8 @@ Cross-doc terminology is collected in [GLOSSARY.md](GLOSSARY.md) `50-api.toml` fragment), live reload (mappings/budgets/CH-conn/table selection/pause) with no restart, config-driven table opt-in, pause as `[stream] paused`, `Reloader` (no session lifecycle) -- [oracle.md](oracle.md) — differential decode oracle, walshadow PG - extension, `--validate` sampling +- [oracle.md](oracle.md) — PgPending resolver, walshadow PG + extension - [clickhouse-c-rs Safety model](../clickhouse-c-rs/README.md#safety-model) — FFI trust boundary, `Client<'fd>` lifetime shape, `PosixIo` `BorrowedFd` discipline, packet-payload union diff --git a/plans/TOAST.md b/plans/TOAST.md index 51da5a89..3449c0f2 100644 --- a/plans/TOAST.md +++ b/plans/TOAST.md @@ -148,7 +148,8 @@ logically-logged rels, never toast): truncating xact commits — they stash and decode at commit (see Rewrite generations), their births ordering past the wipe by record LSN. - **DROP** (owner DROP, or a rewrite retiring its old toast rel) surfaces - the toast rel's `Dropped` via `sweep_dropped`; reorder queues the retire + the toast rel's `Dropped` via descriptor capture (absent from the + boundary's fetch with a Present predecessor); reorder queues the retire and executes it — emptied, table kept, counted `toast_mirror_retires` — only once the persisted resolved floor passes the dropping commit. Deferral is what makes the wipe replay-safe: durability of @@ -176,12 +177,10 @@ logically-logged rels, never toast): are the operator-drop lever once the slot passes the dropping commit. Owner TRUNCATE's wipe needs no deferral: a replayed pre-truncate referrer may fill, but the destination TRUNCATE re-applies after it in - the same replayed barrier order. Sweep - only surfaces oids in `prev_known`: baseline seeding resolves pinned - owners' toast rels at boot, so a pinned owner's DROP retires the mirror - with no post-restart chunk decode; an auto-create rel dropped before any - post-restart touch keeps its stale mirror (storage-only, the rewrite - posture below). + the same replayed barrier order. Capture covers toast rels alongside + their owners (the boot seed and every boundary's inval set include + them), so a pinned owner's DROP retires the mirror with no + post-restart chunk decode. - **Rewrite generations** (VACUUM FULL / CLUSTER / rewriting ALTER, and the same-xact CREATE/TRUNCATE + INSERT siblings): the new toast heap fills through ordinary `XLOG_HEAP_INSERT`s on a filenode whose pg_class @@ -190,8 +189,8 @@ logically-logged rels, never toast): stashes those records raw in the xact spill (`SpillEntry::Raw`) — admission gated on having seen the filenode's `XLOG_SMGR_CREATE` marker, which doubles as completeness proof (records cannot precede creation). - At commit, `resolve_stash` looks each filenode up via - `relation_at(rfn, commit_lsn)`: content swap resolves to the original + At commit, `resolve_stash` looks each filenode up in the descriptor + log at the commit's `next_lsn`: content swap resolves to the original toast oid with value ids preserved (`rd_toastoid`), link swap to the surviving transient toast oid with fresh ids and the old mirror retiring via the DROP path above. Resolved toast records decode in the drain @@ -206,10 +205,10 @@ logically-logged rels, never toast): fetchable. Filenodes unresolvable post-commit (created-and-dropped in one xact, or rotated by a later replayed commit) discard their records (`toast_stash_discarded`) — access-exclusive supersession makes that - end-state-neutral. Records resolving to ordinary heaps stay fenced off - (`toast_stash_skipped`): lower-bound `relation_at` could return a later - same-filenode schema, so main-tuple decode waits on a shadow replay - fence. A toast resolution without its marker (observation began + end-state-neutral. Records resolving to ordinary heaps decode at drain + under their commit-resolution descriptor ([xact.md](xact.md) + Commit-time stash). A toast + resolution without its marker (observation began mid-xact) fails closed — fresh snapshot — rather than emitting an unauditable partial generation. Physical copies (SET TABLESPACE / SET LOGGED) FPI the toast heap verbatim with TIDs, bytes, and value ids diff --git a/plans/config.md b/plans/config.md index f75fc66e..ff166b8c 100644 --- a/plans/config.md +++ b/plans/config.md @@ -147,24 +147,26 @@ enum DrainEntry { `DrainEntry::Config` events ride the same `ordered_events` interleave and barrier apply as `DrainEntry::Catalog`: interpreted events stamp `(xid, source_lsn)` and -merge into the heap stream by LSN, so a config row preceding heap writes in WAL -position applies before those writes drain. One apply site owns the enum: the -pipeline's `run_barrier_batch` in -[`pipeline/reorder.rs`](../src/emit/pipeline/reorder.rs), walking -`DrainedBatch::into_walk` steps. +merge into the heap stream by LSN. One apply site owns the enum: the executor's +plan replay in [`pipeline/reorder.rs`](../src/emit/pipeline/reorder.rs), each +control entry fenced and applied at its pinned walk position. `ConfigResolver::apply_config_event` mutates the overlay, **writes the live `MappingHandle` synchronously under the barrier fence**, bumps `invalidation_epoch` for shape-changing (`Column*`) events, then republishes. -The fenced map write is what makes trailing rows in the applying xact route -against the post-config mapping — the same discipline DDL uses writing -`ShadowCatalog` + bumping the epoch before its trailing heaps dispatch. Routing -through the async `watch`→refresher hop instead would let `run_barrier` dispatch -the trailing segment before the swap took effect, and the decode worker would -miss the mapping and silently drop the row. So WAL config apply writes the map -directly; `watch` republish stays the mechanism only for the barrier-free +`watch` republish stays the mechanism only for the barrier-free contexts (boot seed, SIGHUP) and the DDL applicator's own `DdlConfig` refresh. +Granularity is whole-transaction ([emitter.md](emitter.md) Transaction +planner): each transaction plans entirely under one frozen route state. +The planner's route view folds in-walk **catalog** entries into its +local view (an auto-create `Added` routes same-xact trailing rows), but +**config** entries deliberately do not — a transaction planned before a +config commit routes wholly under pre-config state, one planned after +wholly under post; no transaction ever mixes route versions, and a +config row never retroactively re-routes its own xact's rows. The real +side effect lands once, at executor replay, under the fence. + ## Boot seed `bin/stream.rs::seed_runtime_config` runs between catalog seed and pump start, @@ -262,7 +264,8 @@ Consumers snapshot the receiver; the overlay feeds only the resolver merge point, not the consumer set. Four consumers: - **Routing map refresher** (`spawn_mapping_refresher`, `bin/stream.rs`) — on - each republish full-swaps the live `MappingHandle` the decode pool reads. (The + each republish full-swaps the live `MappingHandle` the planner's route view + resolves from. (The WAL apply path writes this handle directly under the fence; the refresher covers the barrier-free republishes) - **DDL applicator** ([`ch_ddl::DdlApplicator`](../src/ch_ddl.rs)) — folds a @@ -328,9 +331,10 @@ observability live in - **Namespace flip.** TOML `auto_create = false`; operator inserts `config_namespace ('public', 'default', true)`. Subsequent `CREATE TABLE public.events(...)` materialises on CH -- **Mapping-add ordering.** Single xact `INSERT config_table` then rows into the - now-mapped table. CH receives the rows under the post-config target, proving - the within-xact fenced-map write +- **Mapping-add ordering.** `INSERT config_table` commits, then rows into the + now-mapped table commit. CH receives the rows under the post-config target; + a transaction planned before the opt-in commit instead discards whole + (whole-transaction granularity, never a mixed-route xact) - **Batch tunables live.** `config_global.row_budget = 1000`, `flush_timeout_ms = 250`; emitter flushes at the smaller trigger. Bump to 100k and observe larger batches — batcher picks up the resolved snapshot mid-pipeline diff --git a/plans/decoder.md b/plans/decoder.md index 91b65375..03a425b7 100644 --- a/plans/decoder.md +++ b/plans/decoder.md @@ -39,7 +39,8 @@ pub type DecodedHeaps = SmallVec<[DecodedHeap; 1]>; `SmallVec<[_; 1]>` keeps single-tuple INSERT / UPDATE / HOT_UPDATE / DELETE stack-allocated; only `XLOG_HEAP2_MULTI_INSERT` with `ntuples > 1` spills to heap. Caller owns `RelDescriptor` resolution -via `ShadowCatalog::relation_at`; `TRUNCATE` is intercepted upstream +via the descriptor log's interval lookup +([desc_log.md](desc_log.md)); `TRUNCATE` is intercepted upstream (`BufferingDecoderSink::handle_truncate`) because its `main_data` carries pg_class OIDs rather than a relfilenode diff --git a/plans/desc_log.md b/plans/desc_log.md new file mode 100644 index 00000000..8dddcf7a --- /dev/null +++ b/plans/desc_log.md @@ -0,0 +1,204 @@ +# descriptor log + +Durable append-only relation-shape history; the decode-side catalog +oracle. Owned by `src/catalog/desc_log.rs`, populated by +`src/source/catalog_capture.rs` at catalog-commit boundaries, read +wait-free by every decode site. + +## Why a log, not a live cache + +Decoders ask a point-in-time question — `descriptor(rfn, L)` for a record +at LSN `L` — while a live shadow-PG query answers "descriptor now". +Bridging that gap with caches, invalidation epochs, baseline ledgers, and +drop sweeps re-implements catalog time travel piecemeal, each piece with +its own race window. The log stores the history itself: per-key version +chains with `valid_from` bounds, so both bounds of every interval hold by +construction and lookups are a binary search, no locks, no SQL, no replay +gate. + +## Capture at boundaries + +The filter classifies any write below `FirstNormalObjectId` (or a tracked +relocated catalog filenode) as catalog and marks the writing xid dirty +([filter.md](filter.md)); mid-xact `XLOG_XACT_INVALIDATIONS` records +re-dirty an xid whose catalog writes precede the restart resume floor. At +that xact's commit it builds a `BoundaryInfo`: the drain xid (prepared +xid for COMMIT PREPARED), affected user oids (pump-side pg_class decodes +∪ the commit record's relcache invalidations), the xact tree's first +catalog-touch LSN, and a capture-all flag (whole-relcache inval, +pg_namespace catcache / whole-catalog inval, or a write to a catalog +whose effects invals don't enumerate — see +[future/catalog_capture_completeness.md](future/catalog_capture_completeness.md)). +Classification off the commit record alone is restart-safe: it carries +the xact tree's full inval set, so a boundary is recognized even when +every catalog record replayed before a crash. + +`BoundaryHoldSink` sequences the boundary inside the publication hold +([source.md](source.md)): + +```text +flush predecessors → hold (shadow applies through next_lsn) → +capture (SQL fan-out → entries + events → append + fdatasync → +index publish → events into XactBuffer) → forward commit record +``` + +Nothing past the commit exists on wire, archive, or worker queue during +capture, so the SQL snapshot *is* the commit's catalog state +(`pg_last_wal_replay_lsn() == next_lsn`, enforced fatal), and any record +reaching a decoder already has coverage. + +## Entries, events, intervals + +Per captured oid, capture diffs the fresh descriptor against the oid's +log predecessor: + +- none/Dropped predecessor → `Present` entry + `Added` event +- shape change → `Present` entry (+ `Changed` when `compute_schema_diff` + is non-empty; the diff is attribute-based, renames recapture silently) +- filenode rotation (rewrite/TRUNCATE/SET TABLESPACE) → `Retired` entry + closing the old rfn chain, no event — AccessExclusiveLock means no + decode query lands past the rotation; the entry exists so GC drops the + chain and buggy callers fail closed +- absent from capture with a Present predecessor → `Dropped` tombstone + + event at `next_lsn` + +`valid_from` biases early — a descriptor is a backward-compatible reader +of older tuples (missing attrs → default/NULL; dropped columns keep their +physical slots), never the reverse. Sources in preference order: the +rfn's `XLOG_SMGR_CREATE` marker (pump-side map, before any page write), +the oid's first pg_class touch in the xact, the tree's first catalog +touch. Events enter the drain keyed at `valid_from`, sorted with config +events at drain open. + +Bias-early is only sound when the final descriptor provably reads every +tuple in the interval. `compatible_reader` +([`src/catalog/compat.rs`](../src/catalog/compat.rs)) is the predicate, +and it classifies rejects by physical consequence: + +- proven: metadata-only changes (renames, defaults) and appended nullable + / missing-value columns +- `Benign` — declared shape drifts, every byte reads the same: typmod, and + attstorage. The walk reads attlen/attalign/attbyval only and varlena / + numeric datums are self-describing, so bias-early still holds and + nothing is published +- `Physical` — no descriptor reads both formats: type oid, typlen, + alignment, attnum reuse, missing-value edit, not-null append without a + missing value, relkind / persistence / toast-relation change + +A physical in-place change (same rfn, no rotation) publishes an +`Ambiguity` interval `[first_touch, next_lsn)` alongside the final +`Present`, which lands at `next_lsn`: within the interval no single +descriptor provably decodes the rfn's rows. One interval per identity key +the verdict can name — `Rfn` then `Oid`, fixed order since batch equality +and digest gate append idempotency — so the rfn-keyed decode path and the +oid-keyed truncate path see the same fence without either consulting the +other's map. `Database` scope stays the conservative fallback for +unenumerable relations. Reason is `UnknownMutationPosition`: only the +first catalog touch is tracked, so the mutation's exact position inside +the interval is unknown. Ambiguities are batch records like entries — +replay-from-log reproduces the same intervals every boot. + +PG rewrites for every ALTER that moves tuple layout, and a rotation skips +the predicate, so a physical in-place verdict describes shapes PG does not +currently produce. The fence is a guard against unmodelled ones, not a +routine path. + +Toast rels ('t') capture entries and `Dropped` events only (the retire +ledger consumes those); indexes are excluded entirely. + +## Replay-from-log + +Every boundary appends a batch keyed `captured_at = next_lsn` — a +zero-entry stub when nothing changed. Boot loads ckpt + tail, then the +WAL re-read finds each boundary's batch already stored and derives events +from the stored entries against `predecessor_before(oid, captured_at)` +(the historical predecessor, never the loaded head) — no SQL, identical +events every replay. A miss with shadow replayed past the boundary means +the log lost coverage: fatal, remedy `--ignore-cursor` (which deletes the +log) or re-bootstrap. The manifest version gates pre-log spill dirs the +same way ([ops.md](ops.md)). + +## Seed + coverage horizon + +An empty log seeds one batch from `fetch_all_descriptors` (every rel +`relkind IN ('r','p','m','t')`, oid ≥ 16384) at the raw resume position, +entries valid from the aligned start, persisting `covered_through` in the +ckpt. The aligned-prefix re-read decodes against the seed; boundaries at +or below `covered_through` skip capture and event replay (baked into the +snapshot); `NotCovered` at or below it is a counted row skip (rel died +pre-snapshot). Every boot also runs a boot-`Added` pass over the log's +active Present set — auto-create namespaces and opted-in mapped rels get +their idempotent `CREATE TABLE IF NOT EXISTS` at attach, and newly +enabled config picks up existing rels without log mutation. + +## Decode reads + +`descriptor_at(rfn, lsn)` / `descriptor_by_oid_at(oid, lsn)` return +`Present | Ambiguous | Dropped | Retired | NotCovered | ForeignDb`. +Ambiguity precedes the chain — a chain entry inside an ambiguous +interval is not proven safe for rows there, so the interval check runs +first (rfn/oid scope, then database scope). The `_spanned` twins return +the `Present` descriptor plus its entry's `valid_from` under one index +read (two reads could interleave with a bias-early append and pair a +stale descriptor with a fresh span); `present_before` serves historical +predecessors: + +- worker buffering: Present decodes; ForeignDb and horizon/xid-0 + NotCovered are counted skips; NotCovered/Dropped with a live xid stash + for commit-time resolution ([xact.md](xact.md) Commit-time stash); + Ambiguous stashes when the filenode is marker-proven and fails closed + when it is not (a markerless stash keeps no payload, so discarding + would be silent row loss); Retired skips (rows can't outlive the + rotation) +- stash resolution at commit `next_lsn`: Present toast → chunk decode + behind its marker barrier, Present ordinary → raw decode under the + commit-resolution descriptor. Resolution asks at `next_lsn`, which sits + past every interval the commit published, so the fence is a separate + query — `ambiguities_intersecting(rfn, oid, first stashed lsn, + next_lsn)` rides the verdict and the drain fails closed per record whose + own `source_lsn` lands inside one. Tombstones discard: under + AccessExclusiveLock no row on a dropped or rotated filenode outlives the + commit +- planning: descriptors ride each heap's envelope from the buffering / + stash step; the planner never re-resolves ([emitter.md](emitter.md)) +- TRUNCATE fan-out resolves by oid; the barrier apply falls back to the + rfn chain's last Present when the truncating commit itself retired the + rfn (rotation's `Retired` lands before the truncate record) + +## Storage + +`desc_log.ckpt` + `desc_log.tail` under the spill dir. Shared binary +header binds pg major, system id, timeline, db oid, and segment size — +mismatch fatal, mirroring the manifest's foreign-source gate. Frames are +`[len u32][crc32c][body]`; the ckpt (written via `fs::write_atomic`) +carries a meta frame (`covered_through`, `floor_at_write`) plus compacted +batches; the tail is fdatasynced per boundary. A torn final frame +truncates durably at load; interior CRC failure is fatal. One writer +mutex serialises append and GC; readers take an RwLock'd index snapshot +published only after fsync. + +GC runs off the pump task, fed each persisted floor over a watch channel +([ops.md](ops.md)) so a ckpt rewrite never stalls WAL consumption into the +source's `wal_sender_timeout`; failures set a fatal the pump surfaces on +its next turn. Boundary capture still shares the writer mutex, so a +boundary landing mid-compaction blocks its hold. Retention: per key the +entry active at the floor survives when Present; a Dropped/Retired there +drops the whole at-or-below chain (nothing above can reference it — +records predate the drop and the floor never exceeds the re-read start); +batches above the floor survive whole, stubs included, and an interval +survives while `through_lsn` is above the floor. Thresholds: ≥512 +droppable entries or an 8 MiB tail. + +Identity keys the full physical `RelFileNode`: relfilenumbers are unique +only per database of one tablespace +([future/TABLESPACES.md](future/TABLESPACES.md) §0), so `(db_node, +rel_node)` alone can alias two live relations. Capture resolves the +`pg_class.reltablespace` 0 sentinel to the database's `dattablespace`, +making stored rfns directly comparable to WAL locators' physical spcOid. + +## Metrics + +`walshadow_desc_capture_*` (sql / log_replay / skipped_covered / +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. diff --git a/plans/emitter.md b/plans/emitter.md index b34b3079..6d206773 100644 --- a/plans/emitter.md +++ b/plans/emitter.md @@ -3,13 +3,14 @@ CH-side ingest is a parallel decode+insert pipeline (`src/pipeline/`): ```text -pump -> QueueingRecordSink -> reorder -> [decode x M] -> InsertBatcher - -> [inserter x N] -> ClickHouse +pump -> QueueingRecordSink -> reorder (plan -> execute) -> [decode x M] + -> InsertBatcher -> [inserter x N] -> ClickHouse \-> ack collector -> emitter_ack_lsn ``` -Pipeline stages live in `src/pipeline/{reorder,decode,batcher,inserter, -ack,tail,mod}.rs`; encoding primitives (`EmitterConfig`, `TableEncoder`, +Pipeline stages live in `src/pipeline/{reorder,planner,plan_spool, +decode,batcher,inserter,ack,tail,mod}.rs`; encoding primitives +(`EmitterConfig`, `TableEncoder`, `TablePlan`, `ColumnBuf`, value encode, `EmitterStats`) in `src/ch_emitter.rs`; DDL in `src/ch_ddl.rs`; PG → CH type mapping in `src/type_bridge.rs`. Pool sizes M/N come from `--decoder-pool-size` / @@ -21,9 +22,8 @@ Metrics-only runs (no `--ch-config`) stand up the same pipeline in its degenerate configuration: `TailKind::Null` swaps batcher + inserters for a swallow task that acks rows at receipt (zero CH connections), no `DdlApplicator` (schema events observed, never applied), empty mapping -so the decode pool routes nothing and seqs complete at placement — -watermark, idle advance, and slot semantics identical to a CH run. -Oracle `--validate` sampling runs in the decode pool either way +so planning routes nothing and seqs complete at placement — +watermark, idle advance, and slot semantics identical to a CH run ## Purpose @@ -46,12 +46,16 @@ Single-threaded commit-order boundary. Runs as inner sink of the daemon's `QueueingRecordSink` (off the WAL pump task, so replay gates never pace wire delivery). Only `RM_XACT_ID` records reach its match: -- COMMIT — poll-based DROP sweep (only when this commit's xid set was - armed by a pg_class heap_delete, `PendingSweeps`), - `XactBuffer::drain_committed`, then assign one dense `seq`, - `ack.register(seq, commit_lsn)`, dispatch a `DecodeJob` to the - decode pool. Empty commits register a rows=0 seq so the contiguous - watermark never gaps +- COMMIT — stash resolution against the descriptor log at the commit's + `next_lsn`, then plan-then-execute: + `XactBuffer::drain_committed` under the drain xid (prepared xid for + COMMIT PREPARED) streams through the transaction planner into a + sealed plan (side-effect-free, see Planner below); `execute_plan` + replays it — heap segments dispatch as `DecodeJob` seqs after + `ack.register(seq, commit_lsn)`, control entries fence and apply at + their pinned positions. A plan error abandons the transaction before + any side effect. Empty commits register a rows=0 seq so the + contiguous watermark never gaps - ABORT — drop buffer state, register + place a rows=0 seq (never a direct ack bump; everything moves through the gate) - ASSIGNMENT — feed `SubxactTracker` @@ -82,12 +86,60 @@ coarseness is deliberate; DDL and TRUNCATE are rare. Trailing data after the last event flows async, already encoding against the post-DDL shape +### Transaction planner — `pipeline/planner.rs` + `pipeline/plan_spool.rs` + +Side-effect-free planning stage between drain and execution. Consumes +committed-drain batches in walk order and streams them into one plan +per transaction: heaps detoast and route at planning so the executor +never re-resolves, raw stashed records decode under their commit +verdict descriptors ([xact.md](xact.md) Commit-time stash), control +entries pin their walk positions, mirror-row refs and truncate fences +carry through re-based to plan-global indices. Every input-derived +failure — descriptor, decode, toast, route — surfaces before the first +transaction side effect; a plan error drops the writer, the file +unlinks, the transaction emits nothing + +Forbidden side effects are unrepresentable, not merely avoided: the +planner holds no ack handle, no batcher channel, no CH client, no +config applicator. Route state folds into a `PlanRouteView` resolving +from frozen versions; in-walk control entries fold into the LOCAL view +only — global mapping/config/CH changes belong to the executor at +replay. This is what makes config changes whole-transaction-granular: +a transaction plans entirely under one route state, never mixes +versions ([config.md](config.md)). `route_for` returning `None` is the +deterministic unmapped discard, counted, planned as `route_id = +u32::MAX` — it skips detoast and codec work entirely + +The plan itself is a transient validated spool (`plan_spool.rs`): +plans at or below `DEFAULT_PLAN_MEM_MAX` (1 MiB) stay memory-resident +so the common single-statement commit never touches the filesystem; +larger plans stream to a `.plan` file. Frame layout after the `WP` +magic + version header: `[len:u32][crc32c:u32][body]`, body tag 0 = +heap (`dict_id + route_id + heap bytes`, spill codec), tag 1 = seal +(`heap_count`). Bounded metadata — descriptor dictionary, route table, +control entries, row batches — stays resident in the plan header. A +missing seal means planning never finished; trailing bytes mean +corruption; file-backed plans checksum-verify fully before the first +side effect. Files are never durable: source-WAL reconstructible, +unlinked on success and failure alike, swept at startup by +`clean_plan_files` via the spill-dir clear + +Validation coverage, one enforcement point per plan-success guarantee: +descriptor Present + unambiguous (stash verdicts at drain), operation +supported with logical tuple data (raw operation policy), decoded xid +owned by the xact family (`ForeignXid`), partial update fails the plan +(`PlanError::PartialUpdate`; no reconstruction path exists), needed +toast values resolve at planning, route snapshot complete by +construction, planned schema transitions carry old + new descriptors +into replay verbatim + ### Decode pool — `pipeline/decode.rs`, ×M -Each worker pulls a `DecodeJob` and per heap: `detoast_heap`, resolve -descriptor via `shadow_catalog::resolve_at_pooled` (read-only pooled -resolve, replay-LSN-gated), mapping lookup, oracle `PgPending` -resolution + sampled validation, then routes `RoutedRow`s to the +Each worker pulls a `DecodeJob` of planned `RoutedHeap` envelopes — +descriptor and route ride each envelope, nothing here resolves catalog +or mapping state. Per heap: `detoast_heap` (values already resolved at +planning, chunks ride empty), oracle `PgPending` +resolution, then routes `RoutedRow`s to the batcher in chunks (`DECODE_CHUNK_ROWS = 1024` / `DECODE_CHUNK_BYTES = 4 MiB`, amortizing the channel hop). After the xact's last row it reports `Placed { seq, rows }`. Decode errors are fatal — a @@ -354,12 +406,14 @@ columns = [ ``` `MappingHandle = Arc>>` -is the live handle the decode pool consults per row. Handle is -cloneable; daemon's SIGHUP task swaps whole inner `HashMap`. Routing -picks up the swap immediately; the batcher's cached `TableEncoder` -keeps its old `TablePlan` until the next barrier `FlushAll` (or -restart) rebuilds it — a SIGHUP retarget therefore fully applies only -at the next DDL/TRUNCATE boundary +is the live handle the planner's route view resolves from. Handle is +cloneable; daemon's SIGHUP task swaps whole inner `HashMap`. Routes +freeze into each transaction's plan as `RouteSnapshot`s — a mapping +write after planning can never alter a planned row, and the swap takes +effect at the next transaction's plan. The batcher's cached +`TableEncoder` keeps its old `TablePlan` until the next barrier +`FlushAll` (or restart) rebuilds it — a SIGHUP retarget therefore +fully applies only at the next DDL/TRUNCATE boundary ### NamespaceMapping (partial) @@ -394,9 +448,9 @@ database the resolver substrate ([config.md](config.md)): `ResolvedConfig` (`tables` + `namespaces` + `columns` type-override table) published on a `watch::Receiver>`, CLI > PG-row > TOML merge, SIGHUP -republish. The decode pool reads `Arc>` for the per-row hot -path; a refresher bridges the watch snapshot into it. The richer namespace -surface is not covered: +republish. The planner's route view reads `Arc>` when +freezing routes; a refresher bridges the watch snapshot into it. The +richer namespace surface is not covered: - `NamespaceMapping.order_by_default`: `render_create_table` hard-codes `ORDER BY (_lsn)` fallback when no PK exists @@ -411,11 +465,10 @@ the source-PG-driven work (signals, opt-in + backfill, net-new knobs) ## DdlApplicator `ch_ddl.rs::DdlApplicator`, owned by the reorder coordinator. Events -originate at `ShadowCatalog::subscribe` -(`mpsc::UnboundedReceiver` — unbounded so a stalled -consumer never back-pressures the catalog producer), ride the xact -buffer keyed on `(xid, source_lsn)`, and surface in `drain_committed`'s -`ordered_events`; the barrier applies each in source-LSN order. +originate at descriptor capture ([desc_log.md](desc_log.md)) as log +diffs, ride the xact buffer keyed `(drain_xid, valid_from)`, and +surface in `drain_committed`'s `ordered_events`; the barrier applies +each in LSN order. `DrainEntry::ToastBarrier` rides the same loop at commit LSN: the put-cursor flushes the generation's births first, then the barrier runs the store-side residual insert-select ([TOAST.md](TOAST.md)). Apply @@ -448,45 +501,26 @@ DDL has no retry: an applicator error trips fatal so the operator sees it directly. Runtime-config-from-PG work may add bounded reconnect for the DDL connection -### Baseline seeding (the `Added`-vs-`Changed` discriminator) - -Whether a relation's first post-start descriptor fetch surfaces as -`Added` or `Changed` keys on whether `ShadowCatalog::prev_known` already -holds its oid. `prev_known` is the *baseline ledger* (last source shape -CH and source agreed on), not the descriptor cache — cold at every boot, -never reconstructed on a miss. Left cold, a pinned table that sees no DML -before its first `ALTER` records the post-ALTER shape as `Added`, which -`apply_added` skips for pinned dests (operator-managed CH) → CH stays a -column behind. - -`ShadowCatalog::seed_baseline(rel_names)` warms `prev_known` for -every pinned relation before `subscribe()` so the cache never decides the -branch: `bin/stream.rs` calls it after preflight / before -`START_REPLICATION` over `cfg.tables.keys()` (the inproc harness mirrors -it before its own `subscribe`). Pre-subscribe, `send_event` is a no-op, -so seeding emits nothing and does zero CH work. The first post-boot -`ALTER` then diffs the evolved descriptor against the seeded boot shape → -`Changed` → the `apply_changed` path above runs the CH ALTER. - -Seeds the *full source* descriptor, never the mapping: a pinned subset's -unmapped columns sit in the baseline and read as "operator-excluded", so -a later `ALTER` adds only genuinely-new columns, never re-adds an -excluded one. Auto-create tables need no seeding — their first-touch -`Added` → `CREATE TABLE` already records a baseline. - -`config_table` opt-ins warm the same ledger through the opt-in dispatch -(`src/opt_in.rs`): the descriptor resolve inside `apply_table_opt_in` -records the baseline via `record_descriptor` — at the config row's -commit LSN on a live opt-in, at boot on the re-run over seeded rows. So -a post-opt-in `ALTER` diffs against the opt-in shape, never trips the -cold-`prev_known` → `Added` path. The boot re-run fires post-`subscribe` -so each opted-in rel enqueues one `Added`; benign — `apply_added` skips -mapped rels (under strategy = drop it re-issues a `CREATE IF NOT EXISTS` -the standing dest no-ops). Together the resolved scope, not just -`cfg.tables`, is warm -for one daemon lifetime. Open: boot-time drift (column added while the -daemon is down folds silently into the seeded baseline) — see -`plans/future/pinned_ddl_baseline.md`. +### Baseline (the `Added`-vs-`Changed` discriminator) + +Whether a relation's first post-start DDL surfaces as `Added` or +`Changed` keys on the descriptor log's predecessor for its oid +([desc_log.md](desc_log.md)) — durable across restarts, seeded at first +attach with every eligible rel's boot shape. A pinned table's first +post-start `ALTER` therefore always diffs against a real baseline → +`Changed` → the `apply_changed` path above runs the CH ALTER; no warm-up +step exists to forget. + +The baseline is the *full source* descriptor, never the mapping: a +pinned subset's unmapped columns sit in the log and read as +"operator-excluded", so a later `ALTER` adds only genuinely-new columns, +never re-adds an excluded one. Auto-create tables and opted-in rels get +an idempotent boot `Added` pass over the log's active Present set each +start (`CREATE TABLE IF NOT EXISTS` no-ops standing dests), so newly +enabled config picks up existing rels at the next boot. Boot-time drift +(column added while the daemon is down) lands as `Changed` at the next +boundary touching the rel — the descriptor log diffs against the stored +shape, not a freshly fetched one. ### Barrier fence (ordering data around DDL) @@ -524,13 +558,15 @@ but doesn't propagate through `DecodedHeap` ## Foreign-DB row skip -Physical replication ships the whole cluster's WAL, so the decode pool -sees heap records for relations in other databases. `resolve_at_pooled` -rejects those with `CatalogError::ForeignDatabase` (filenode resolved -to a `db_node` that's neither the shadow DB nor a shared catalog — see -[shadow.md](shadow.md)). Worker skips the row: no route, no poison, -bump `stats.foreign_db_rows_skipped`; the seq's placed count simply -excludes it so the ack advances past it +Physical replication ships the whole cluster's WAL, so heap records +for relations in other databases reach the decoder sink. The +record-time spanned lookup answers `ForeignDb` (filenode's `db_node` +is neither the shadow DB nor a shared catalog — see +[shadow.md](shadow.md)) and the sink skips the record as a counted +`catalog_not_found`; a foreign filenode that reached the commit-time +stash instead resolves `ForeignDb` at `resolve_stash` and counts +`stash_foreign_db_skipped` once per filenode ([xact.md](xact.md)). +Nothing foreign survives to planning or the decode pool ## Read-time defaults integration diff --git a/plans/filter.md b/plans/filter.md index 916f378f..2a5c213d 100644 --- a/plans/filter.md +++ b/plans/filter.md @@ -75,17 +75,56 @@ Inputs: Closes the "long-running source already rotated a mapped catalog before walshadow attached" hole. Shared catalogs seed under `db_node = 0`, per-db under `current_database()` oid -- DROP TABLE coarse signal — `heap_delete` against current `pg_class` - filenode signals `InvalidateSweep` so ShadowCatalog drops cached - descriptors. No tuple decode (system catalogs default to - `relreplident = 'n'`, WAL omits dying tuple) - -`observe` returns a `CatalogSignal` verdict stamped on the record; the -decoder worker bumps the shared `Arc` invalidation epoch at -its own stream position (a pump-position bump would be consumable -before pre-DDL records finish decoding). `InvalidateSweep` also arms -`PendingSweeps` with the record's xid — the sweep must run at the -dropping xact's own commit (see [shadow.md](shadow.md)) +- DROP TABLE — `heap_delete` against current `pg_class` filenode marks + the xid catalog-dirty like any other pg_class write. No tuple decode + (system catalogs default to `relreplident = 'n'`, WAL omits the dying + tuple); the dropped oid comes from the commit record's relcache + invalidations at the boundary + +`observe` returns an `Observation`: whether the record mutated a tracked +catalog (drives dirty-xid marking, incl the `Special`-class relmap path) +plus a decoded user-rel pg_class oid when block 0 carried one — the +per-oid first-touch source for `BoundaryInfo`. At a dirty xact's commit +the filter builds `BoundaryInfo` (drain xid, affected oids from decodes +∪ relcache invals, tree first-touch, capture-all flag) and stamps it on +the record for descriptor capture ([desc_log.md](desc_log.md)). The +filter also keeps a pump-side `XLOG_SMGR_CREATE` marker map — the +bias-early valid_from source for rotated filenodes + +## Dirty tree + +`src/filter/dirty_tree.rs`: pump-side catalog-dirty transaction tree +behind the `defer_catalog_decode` stamp. Once a xact family touches the +catalog, every subsequent decoder-routed record in that family carries +the flag and the decoder sink stashes it raw for commit-time resolution +([xact.md](xact.md) Commit-time stash) — a Present predecessor +descriptor doesn't prove decodability inside the dirty interval. State +stays keyed by writing xid, never merged eagerly into the top: subxact +abort drops exactly its subtree's observations while sibling and top +dirt survive. Tree links (subxid → top) arrive from record-inline +toplevel xids (`XLR_BLOCK_ID_TOPLEVEL_XID`) and batched +`XLOG_XACT_ASSIGNMENT` records; both name the top directly so links +never chain. Admission asks "is this record's tree dirty" via a +per-root dirty-member count; a subxid whose top is still unknown counts +as its own root — only its own later records defer until a link lands +(prefer excess raw buffering over predecessor decode). Interleaved +clean xacts are untouched; commit/abort clears the family's dirt + +Boundary classification is restart-safe off the commit record alone: its +inval set covers the whole xact tree, so relcache invals enumerate +affected rels and pg_namespace catcache / whole-catalog invals force +capture-all even when the resume floor sits past every catalog record +the xact wrote (dirty tracker never saw them). Catcache messages carry a +syscache id + hash, not oids — only "which catalog" is recoverable, and +`SysCacheIdentifier` values are keyed per WAL page magic (name-sorted +generation shifts ids across majors; each new major needs the id table +audited). Mid-xact `XLOG_XACT_INVALIDATIONS` records (command +boundaries, wal_level=logical) walk the same classification and re-dirty +the writing xid — sharper first-touch than the commit-LSN fallback when +the floor splits an open xact, merged across subxacts at commit like any +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 ## Rewrite path diff --git a/plans/future/INDEX.md b/plans/future/INDEX.md index 36ee7206..75151722 100644 --- a/plans/future/INDEX.md +++ b/plans/future/INDEX.md @@ -3,14 +3,15 @@ Planning-only docs for unbuilt work. Drop new proposals here as they surface; promote into `plans/` once built -* [TABLESPACES.md](TABLESPACES.md) — source-tablespace correctness: bootstrap page-walk + shadow directory materialization +* [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 * [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 * [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 -* [xact_stash.md](xact_stash.md) — generic commit-time raw-record decode: ordinary-heap CREATE/TRUNCATE + INSERT via shadow publication fence + durable descriptor snapshots * [ch_bounce_recovery.md](ch_bounce_recovery.md) — deeper re-emit-from-spill on retry-budget exhaustion * [pinned_ddl_baseline.md](pinned_ddl_baseline.md) — schema-event outcome must be a function of config + baseline, not cache warmth: CH-existence / persisted-baseline options for cross-restart consistency, drop detection across downtime, opt-in mapping vs republish * [coverage100.md](coverage100.md) — drive `cargo llvm-cov` line coverage toward 100%: tiered work list (pure units → fixtures → live e2e → hard tail) diff --git a/plans/future/TABLESPACES.md b/plans/future/TABLESPACES.md index 492ccda2..f028370e 100644 --- a/plans/future/TABLESPACES.md +++ b/plans/future/TABLESPACES.md @@ -10,37 +10,50 @@ that one is a forward-looking emitter rearchitecture. §3 below is the only seam — it exposes the tablespace attribute destination routing consumes as one (weak) key. -## 0. What already works, and why +## 0. Physical identity In PG a relation's physical identity is the `RelFileLocator` -`(spcNode, dbNode, relNode)` (`walrus::pg::walparser::types`). -walshadow keys every relation on `(db_node, rel_node)` and discards -`spc_node` for identity: - -- catalog whitelist: `CatalogTracker.nodes: HashSet<(u32,u32)>` - (`src/catalog_tracker.rs:62`) -- resolver: bootstrap drain calls - `CatalogMap::get(rfn.db_node, rfn.rel_node)`; - `CatalogMap.by_filenode` is `HashMap<(Oid,Oid), _>` - (`src/backup_page_walk.rs`) -- the design comment spelling out why lives at - `src/shadow_catalog.rs:939`: *relfilenode is unique per database - regardless of tablespace* - -That assumption is sound on supported versions (PG 16+). -`GetNewRelFileNumber` (`postgresql/src/backend/catalog/catalog.c:542`) -draws the relfilenumber from the cluster-wide OID counter and, on the -CREATE path, checks it unused in the database's `pg_class`; the rewrite -path additionally rejects a colliding on-disk file. So within one -database no two live relations share a `relNode`, across tablespaces or -not. `spc_node` is redundant for identity, and the heap decoder never -needs it — tuple layout doesn't depend on tablespace. - -**Consequence:** steady-state streaming of a table sitting in a -non-default tablespace already works. The heap WAL record carries the -concrete physical `spcOid`, the filter ignores it, the decoder emits -rows. No code change needed for the decode path. *Verify, don't assume* -— there is no test pinning this; see §4. +`(spcNode, dbNode, relNode)` (`walrus::pg::walparser::types`), and all +three components are load-bearing. `GetNewRelFileNumber` +(`postgresql/src/backend/catalog/catalog.c`) guarantees relfilenumber +uniqueness only *within one database of one tablespace*: its pg_class +probe checks `pg_class.oid` (CREATE path only, where filenumber doubles +as the oid), and the on-disk collision probe builds its path from the +target tablespace. The rewrite / `SET TABLESPACE` path passes no +pg_class at all, and `tablecmds.c` states the consequence outright: +*relfilenumbers are not unique in databases across tablespaces*. After +OID wraparound, two live relations in one database can share `relNode` +under different tablespaces, so `(db_node, rel_node)` is not an +identity. + +Surfaces keying the full physical rfn: + +- descriptor log chains + lookups (`src/catalog/desc_log.rs`); capture + resolves the `pg_class.reltablespace` 0 sentinel to `dattablespace` + in the descriptor SQL (shadow + source), so stored rfns match WAL + locators' concrete spcOid +- `XLOG_SMGR_CREATE` markers, pump side (`SmgrMarkers`, + `src/filter/engine.rs`) and worker side (`XactBuffer.markers`, + `src/xact/xact_buffer.rs`) +- decode-path descriptor lookups and the per-job memo + (`src/emit/pipeline/decode.rs`) + +Surfaces still keying `(db_node, rel_node)`, exposed only when +wraparound mints a colliding relfilenumber: + +- catalog whitelist `CatalogTracker.nodes` + (`src/filter/catalog_tracker.rs`) — a user rel aliasing a catalog + filenode misroutes its records to shadow and fabricates boundaries; + no misdecode, but noisy and unbounded shadow growth +- bootstrap `CatalogMap.by_filenode` + page-walk classifier + (`src/backfill/backup_page_walk.rs`) — moot until §1 lands, since + non-default tablespace files never reach the classifier today + +Tuple layout doesn't depend on tablespace, so steady-state streaming of +a table in a non-default tablespace works: heap WAL carries the concrete +physical `spcOid`, descriptors carry the resolved tablespace, decode +matches on full rfn. *Verify, don't assume* — there is no test pinning +this end to end; see §4. `RM_TBLSPC_ID` and `RM_SMGR_ID` records pass the filter verbatim (`src/classify.rs:39`, `rmgr_is_special`), and `pg_tablespace` (shared @@ -67,12 +80,11 @@ in walshadow today (every non-test caller ignores it; tests pass Fix: -1. Teach the path classifier the `pg_tblspc/...` shape. Parse `dbOid` - and `relNode` out of the deeper path, ignore `spcOid` and the - version dir for keying (consistent with `(db,rel)` identity). - Catalog lookup (`self.catalog.get(db, rel)`) is unchanged because - the catalog seed already records the right `(db,rel)` for these - relations +1. Teach the path classifier the `pg_tblspc/...` shape. Parse `spcOid`, + `dbOid` and `relNode` out of the deeper path (version dir skipped) + and key the `CatalogMap` on the full rfn per §0 — the catalog seed + already records resolved physical tablespaces, so lookups compare + directly 2. Confirm the BASE_BACKUP actually ships non-default tablespace contents in the stream walshadow consumes. In `BASE_BACKUP`, each tablespace is a separate tar with its own root; the source @@ -138,16 +150,15 @@ covered by the catalog generation bump (overview pitfall 5, ## 3. Tablespace as an emitter-visible attribute [DESTINATIONS.md](DESTINATIONS.md) wants the tablespace of a relation -at route time. The `RelDescriptor.rfn.spc_node` built from the catalog -(`src/shadow_catalog.rs:1007`, `src/backfill_bootstrap.rs:312`) carries -`pg_class.reltablespace`, which is **`0` for the database-default -tablespace** and the real OID otherwise — distinct from the *physical* -`spcOid` in heap WAL (always concrete). `0` is a convenient "default" -sentinel for routing predicates. Mapping `spcOid -> spcname` for -human-readable config uses `pg_tablespace` (already on shadow); -`reltablespace = 0` resolves to the database's `dattablespace`. This -resolution is the only catalog read destination routing needs; -cache it on the `RelDescriptor` (rare-change), do not query per row. +at route time. `RelDescriptor.rfn.spc_node` carries the *resolved +physical* tablespace OID — the descriptor SQL +(`src/catalog/shadow_catalog.rs`, `src/backfill/backfill_bootstrap.rs`) +maps the `pg_class.reltablespace` 0 sentinel to the database's +`dattablespace`, matching the concrete `spcOid` in heap WAL. A routing +predicate wanting "is in the default tablespace" compares against +`dattablespace` rather than testing for 0. Mapping `spcOid -> spcname` +for human-readable config uses `pg_tablespace` (already on shadow); do +not query per row. ## 4. Tests (this is unshippable without these) @@ -162,6 +173,21 @@ cache it on the `RelDescriptor` (rare-change), do not query per row. - gate all three on `initdb`/`clickhouse` presence, runtime-skip pattern per `[[test-timeouts-stay-short]]` +## 4b. Descriptor-log invariant + +The durable descriptor log (`src/catalog/desc_log.rs`) keys chains by +the full physical `RelFileNode` per §0. The invariant making that +comparison sound: every captured descriptor's `rfn.spc_node` is the +*resolved physical* tablespace OID — the descriptor SQL maps +`reltablespace = 0` through `pg_database.dattablespace`, and shared +relations carry explicit `pg_global` (1664) in `pg_class` — so log keys +and WAL locators live in the same namespace. Any new descriptor +construction site must preserve this resolution or its entries silently +miss every lookup. Rotation detection (`src/source/catalog_capture.rs`) +compares full rfn for the same reason. Unit coverage: two relations +sharing `(db, rel)` under distinct tablespaces resolve independently +(`same_db_rel_across_tablespaces_stay_distinct`). + ## 5. Multi-database adjacency (out of scope, flagged) Tablespaces and multiple source *databases* are often conflated. @@ -195,3 +221,10 @@ covered here. `XLOG_TBLSPC_CREATE` payload layout and `TABLESPACE_VERSION_DIR` differ by major. §2's rewriter must be version-aware, same discipline as the rest of the WAL parser +- **`ALTER DATABASE ... SET TABLESPACE`** relocates every + `reltablespace = 0` relation, preserving relfilenumbers, with no + pg_class writes. Post-move WAL locators carry the new spcOid while + descriptor chains keep the old physical key: DML fails closed + (`NotCovered`), never misdecodes. Detecting a + `pg_database.dattablespace` change and forcing capture-all would make + it survivable without re-bootstrap diff --git a/plans/future/catalog_capture_completeness.md b/plans/future/catalog_capture_completeness.md new file mode 100644 index 00000000..c8a13bb8 --- /dev/null +++ b/plans/future/catalog_capture_completeness.md @@ -0,0 +1,92 @@ +# catalog capture completeness + +Descriptor capture enumerates affected relations from commit-record +relcache invalidations plus pump-side pg_class decodes, falling back to +capture-all when a boundary's effects cannot be enumerated. This doc maps +which catalog writes each mechanism covers, and the residual gaps. + +## Dependency taxonomy: catalog → descriptor field + +`RelDescriptor` embeds state from five catalogs: + +| catalog | fields | enumerated by relcache invals? | +|---|---|---| +| pg_class | rfn, oid, toast_oid, kind, persistence, name, relnamespace | yes — every row change registers a relcache inval for that rel (PG `src/backend/utils/cache/inval.c`) | +| pg_attribute | attributes (physical layout, names, typmods) | yes — same mechanism | +| pg_index | replident pk/key attnums | yes — inval registered for the indexed rel | +| pg_namespace | `rel_name.namespace` text | **no** — namespace rename produces catcache invals only; classified via its syscache ids → capture-all | +| pg_type | `RelAttr.type_name` text | **no** — type rename produces catcache invals only | + +## Capture-all trigger set + +The filter forces `BoundaryInfo::capture_all` when: + +- a dirty xact wrote pg_namespace (tracked by filenode, relocations + followed via pg_class decode) +- the commit record (or a mid-xact `XLOG_XACT_INVALIDATIONS` record) + carries a pg_namespace catcache inval (`NAMESPACENAME` / `NAMESPACEOID`, + syscache ids keyed per WAL page magic) or a whole-catalog inval on oid + 2615 — the restart-safe trigger: commit invals cover the whole xact + tree, so classification survives a resume floor past the pg_namespace + writes +- the commit carries a whole-relcache flush (`relId == 0`) + +Rationale: pg_namespace writes are rare (CREATE/ALTER/DROP SCHEMA), and a +rename silently changes every embedded namespace text with zero +per-relation invals — decode would route rows under the old name +indefinitely. + +Catcache ids are the one per-major surface: `SysCacheIdentifier` values +come from name-sorted generation (PG `src/backend/catalog/genbki.pl`; +stable branches append via Z-prefixed names so ids hold within a major). +Each new major needs the namespace-id pair audited. + +pg_type is **consciously excluded** from the trigger: CREATE TABLE writes +pg_type on every run (composite + array type rows), so capture-all would +fire constantly and reduce the enumerated path to dead code. + +## Residual gaps + +- **Payload-free stash resolving decodable.** A record whose filenode was + invisible at record time *and* had no `XLOG_SMGR_CREATE` marker is tracked + without payload (`track_unresolvable`), because the set cannot prove + completeness. If that filenode later resolves `Ordinary` at commit, those + rows are gone; `resolve_stash` warns rather than failing closed, since the + common markerless shapes (born and gone inside the xact family) resolve to + a tombstone and discard legitimately. Closing it needs the marker set to + be complete, or per-record tracking that distinguishes "no marker yet" + from "never observable". +- **`type_name` staleness after `ALTER TYPE ... RENAME`.** Bounded: decode + never reads `type_name` (physical layout comes from + attlen/attalign/attbyval), and every SQL capture re-reads live typname — + only a rel never recaptured after the rename carries the old text into a + boot `Added` (CH type mapping consults it at CREATE). Remediations, in + preference order: + 1. decode catcache invals for the pg_type syscaches and resolve hash + keys → oids via a shadow-side reverse probe (catcache messages carry + hash values, not oids — needs a `pg_type` scan per hit) + 2. maintain a type-oid → dependent-rel reverse index from captured + descriptors; a pg_type write with a hit forces recapture of the + dependents + 3. add pg_type to the capture-all trigger gated on "no relcache invals + in the same commit" (heuristic: renames travel alone; CREATE TABLE + always carries its rel's inval) +- **Rename events.** `compute_schema_diff` is attribute-based; a + namespace or relation rename recaptures fresh descriptors (routing + stays correct) but emits no `Changed`, so CH-side artifacts keep the old + name until an operator intervenes. Surfacing renames as first-class + events needs a diff over `rel_name` plus an applicator strategy + (CH `RENAME TABLE` vs create-and-cutover). +- **Toast-spool retire on rotation.** A `Retired` entry (filenode + rotation) does not feed the toast retire ledger; only relation-level + `Dropped` does. A rewritten toast heap's spooled chunks for the old + generation linger until the owner drops. Wiring `Retired`-on-toast into + the ledger closes the leak; needs the same floor-gated deferral as + `Dropped` retires. + +## Full-rfn keying + +Cross-tablespace identity is a separate concern: +[TABLESPACES.md](TABLESPACES.md) §0 owns the `(db, rel)` keying decision +and §desc-log seam records what flipping the descriptor-log key to a full +`RelFileNode` would require. diff --git a/plans/future/descriptor_timeline.md b/plans/future/descriptor_timeline.md new file mode 100644 index 00000000..587f217c --- /dev/null +++ b/plans/future/descriptor_timeline.md @@ -0,0 +1,84 @@ +# Intra-xact descriptor timeline + +Per-record layout fidelity inside a catalog-dirty transaction: describe +every deferred record with the descriptor its tuple was actually written +under, instead of fencing the span where that is unprovable. + +## What the fence leaves on the table + +Capture samples the catalog once, at the commit boundary. A physically +incompatible in-place transition therefore publishes +`Ambiguity[first_touch, next_lsn)` and the drain fails closed for every +stashed record inside it ([desc_log.md](../desc_log.md), +[xact.md](../xact.md)). The fence is sound and, after the benign/physical +split, near-unreachable — PG rewrites the relation for every ALTER that +moves tuple layout, and a rotation skips the predicate. What stays +unavailable is the middle ground: a record that provably postdates the +layout change still fails closed, because nothing in the boundary's +evidence says *where* inside the interval the change landed. + +## Why the two cheap routes don't work + +**Ask at commit time.** The information is not there. `BoundaryInfo` carries +the tree's first catalog touch and per-oid first pg_class touch; neither +bounds the mutation's position, and narrowing `through_lsn` to the tree's +*last* catalog touch trades a fence miss for a false positive — a second +DDL statement after the DML re-widens the interval, and for oids known only +from commit relcache invals the tree's touch span does not attribute the +change to that transaction at all. + +**Reconstruct from WAL.** Extending `pg_class_decoder` to pg_attribute +would give per-relation attribution and a change position. It fails on the +operation that matters: catalogs are excluded from +`RelationIsLogicallyLogged`, so `log_heap_update` prefix-compresses catalog +updates, and `attrelid` — column 1, offset 0 — sits inside the elided +prefix that shares bytes with the old tuple. Recovering it needs the old +tuple, i.e. a TID-keyed pg_attribute row cache maintained from the same WAL +stream plus a boot snapshot: a historic relcache, reimplemented. + +## Shape + +PG marks intra-xact command boundaries for free. At `wal_level=logical`, +`LogLogicalInvalidations` writes `XLOG_XACT_INVALIDATIONS` at each command +end, and a relation's layout can only change at a command boundary. So: + +1. **Subdivide the hold.** The pump already holds publication at catalog + commits until shadow replays through `next_lsn`. Extend that to + `XLOG_XACT_INVALIDATIONS` records of a dirty xact — one hold per DDL + command, not per record. +2. **Read the in-flight state.** At a hold at LSN `L` the shadow has + replayed exactly through `L`, so the xact's catalog rows are on-page but + uncommitted; no MVCC snapshot sees them. A pgext function scanning with + `SnapshotAny`, filtered to `xmin == the writing xid` and not deleted by + it, reproduces a historic snapshot for that one transaction and yields + the descriptor as of `L`. This reuses PG's own tuple interpretation + rather than duplicating per-major catalog layouts. +3. **Store a timeline.** The batch grows from one entry per changed + relation to one per (relation, command boundary), each `valid_from` at + its command's end. Replay-from-log keeps working unchanged: entries are + already the durable record of the verdict. +4. **Resolve per record.** `resolve_stash` stops resolving one descriptor + per filenode and instead hands the drain the filenode's timeline; each + stashed record folds under the entry covering its own `source_lsn`. The + ambiguity fence stays as the backstop for whatever the timeline cannot + cover (a relation the pgext read fails on, an unmodelled catalog shape). + +## Cost + +One extra hold + one shadow query per DDL command inside a dirty xact. +Nothing changes for clean transactions, and `BEGIN; CREATE TABLE; COPY;` +pays for the CREATE, not for the COPY's records. Against today's model the +new expense is holding at non-commit positions, which delays publication of +successor bytes in the same way commit boundaries already do. + +## Open questions + +- Subxacts: an aborted subtree's command boundaries must drop with it, same + as `DirtyTree::drain_tree` handles observations today. +- Concurrent writers: an `SnapshotAny` scan sees other transactions' + uncommitted rows too. The xid filter handles the common case; a relation + written by two in-flight xacts needs the AccessExclusiveLock argument + spelled out, or a fence for the residue. +- pgext availability: the oracle path is optional today. A timeline that + only works when the extension is installed needs the fence to remain the + fallback, not a hard dependency. diff --git a/plans/future/xact_stash.md b/plans/future/xact_stash.md deleted file mode 100644 index 7f8321fd..00000000 --- a/plans/future/xact_stash.md +++ /dev/null @@ -1,803 +0,0 @@ -# xact stash: generic raw-record decode from catalog history - -Status: future, extends commit-time stash in [xact.md](../xact.md) and -[TOAST.md](../TOAST.md) - -## Decision - -Do not generalize toast stash with per-commit resolution bundles - -Build one durable, LSN-indexed catalog history and make every WAL heap decoder -read descriptors from it. Capture full relation descriptors from shadow at -catalog commit boundaries, persist each capture before publishing successor -WAL, then answer `descriptor_at(rfn, lsn)` from bounded intervals rather than -current shadow state - -Treat generic stash decode as first consumer of this history, not as owner of -another consistency protocol. Same source must serve live rows, delayed rows, -spilled rows, toast ownership, schema events, restart replay, and future -prepared-xact or rewrite consumers - -Five changes form one dependency chain: - -1. replace independent restart and GC calculations with one durable manifest -2. collapse serial and pipeline drain consumers into one ordered apply path -3. capture durable descriptor history at catalog boundaries -4. put routing and shape config in WAL order, split descriptor and route stages -5. enable ordinary raw decode only after all preceding prerequisites land - -Reject prior `ResolutionBundle` design. A stash-only fence leaves identical -cold-cache race on live rows, while bundle contents grow toward a per-commit -snapshot of every decode input. One sparse history closes both paths and -removes bundle identity, lineage-proof, retention, and resolver-lane protocols - -## Current gap - -`StashOutcome::Skip` drops every non-toast stash candidate. Committed rows do -not reach ClickHouse for common cases: - -- `BEGIN; CREATE TABLE; COPY; COMMIT` -- existing-table `TRUNCATE` plus reload in one xact - -Directly changing `Skip` to ordinary decode is unsafe. Correct tuple decode at -record LSN `L` requires `descriptor(rfn, L)`. `relation_at(rfn, L)` only waits -for `shadow_replay >= L`, then queries current catalog state at some unbounded -later position. Worker lag, cache eviction, or restart can therefore substitute -post-`ALTER` shape for pre-`ALTER` row - -Race affects live path too: - -1. row for existing relation appears before `ALTER` -2. decoder worker falls behind -3. shadow replays through `ALTER` -4. descriptor cache is cold or evicted -5. lookup for old row fetches post-`ALTER` descriptor - -Carrying fetched descriptor downstream prevents later re-resolution, but does -not make initial fetch correct. Name, replica identity, dropped positions, -column overrides, and encoding plan can all change, not only tuple width - -Current inputs also follow four clocks: - -- shadow replay position -- decoder and drain position -- config state from WAL rows, boot seed, and SIGHUP -- live Oracle conversion time - -Replay stability needs one WAL-positioned ordering domain, not pairwise bridges -between clocks - -## Scope - -This plan covers: - -- descriptor history for every live and stashed heap record -- ordinary `INSERT`, `MULTI_INSERT`, `UPDATE`, `HOT_UPDATE`, and `DELETE` - operations already supported by heap decoder -- same-xact `CREATE` plus writes -- same-xact `TRUNCATE` plus writes when resulting generation is catalog-visible -- toast relation identity and main-relation descriptor lookup -- schema-event and routing order -- spill xid fidelity and multi-insert fanout -- restart, source identity, retention, and fail-closed behavior - -Keep outside scope: - -- main-heap rewrite FPI decode -- generation never visible in catalog at any capture boundary -- mapped materialized-view replacement semantics -- PREPARE-durable xact buffering -- arbitrary historic MVCC snapshots inside PostgreSQL - -Never-visible generations remain fail closed until FPI capture supplies tuple -and lineage data. Examples include rewrite output, intermediate generations -superseded inside one xact, and some materialized-view refresh paths - -## Architecture - -```text -source wire -> pump/classifier ---- bytes ----> shadow - | ^ - | catalog boundary | hold after commit EndRecPtr - v | - catalog capture lane -----------------+ - | - v - durable metadata journal + manifest - | descriptor intervals - | schema changes - | WAL config changes - v -decoder -> xact buffer -> merged drain -> DescribedHeap - | - apply preceding metadata/config - | - v - RoutedHeap -> emit -``` - -Catalog capture is only live shadow lookup in row-decode architecture. Heap -decode, detoast, schema apply, routing, plan construction, and replay consume -durable metadata history - -Hold shadow publication only at catalog-mutating commits. DML-only commits do -not park. Capture cost remains tied to DDL rate and one local batched snapshot, -never ClickHouse latency or drain backlog - -## Invariants - -1. `descriptor_at(rfn, L)` is durable pure function of source identity, - timeline, database, and WAL position for every decodable generation -2. Decoder never reads descriptor version whose interval begins after row's - effective schema position -3. Successor WAL cannot reach shadow or archive until catalog-boundary capture - covering preceding commit is durable -4. Schema and route inputs apply in WAL order, route attaches only after all - preceding metadata and config events apply -5. WAL replay emits byte-identical rows for equal source records; live Oracle - output and placeholder fallback never feed replayable rows -6. Unknown invalidation, ambiguous interval, missing history, corrupt history, - or never-visible generation fails closed before partial emit -7. One manifest computes actual restart point and one GC cut for every durable - artifact family -8. Publication hold never waits for ClickHouse, committed drain, or queued - barrier work - -## WAL positions - -### PostgreSQL EndRecPtr - -Keep record start and PostgreSQL next-record position distinct: - -```rust -struct Record { - source_lsn: u64, - next_lsn: u64, - wire_end_lsn: u64, - // ... -} -``` - -`source_lsn` remains row version and ordering position. `next_lsn` must match -PostgreSQL `XLogReaderState::EndRecPtr`, not last physical record byte: - -- ordinary record advances by aligned total length -- page-spanning record advances from continuation-page address by aligned - remaining length -- segment-spanning record follows same continuation semantics -- `XLOG_SWITCH` advances to segment end - -Keep `wire_end_lsn` only for byte framing. Never use it for replay comparison - -All capture boundaries use `next_lsn`: - -- wait until `pg_last_wal_replay_lsn() >= commit.next_lsn` -- deliver bytes through commit, hold every successor byte -- stamp capture batch with commit start and `next_lsn` -- compare crash recovery against exact `next_lsn` - -Port arithmetic from PostgreSQL `xlogreader.c`, keep version-specific tests -beside WAL walker - -### Effective schema position - -Each descriptor version carries two positions: - -```rust -struct CatalogVersion { - valid_from: WalPosition, - captured_at: WalPosition, - value: CatalogValue, -} - -enum CatalogValue { - Present(Arc), - Dropped { oid: Oid }, -} -``` - -`captured_at` is catalog commit `next_lsn`, point where shadow snapshot became -visible and journal batch became eligible for publication - -`valid_from` is earliest proven relation-specific schema position: - -- bootstrap baseline uses bootstrap handoff LSN -- new filenode uses observed `XLOG_SMGR_CREATE` marker -- existing relation change uses earliest relation-specific catalog mutation or - invalidation position -- drop uses relation-specific drop observation - -Capture final committed descriptor only. If one xact produces multiple -incompatible layouts for same generation and user rows overlap intermediate -layouts, no single sampled version proves decode. Mark interval ambiguous and -fail closed. Permit compatible transitions only through explicit physical -compatibility predicate, including attnum-preserving rename, replica-identity -change, and append-only column addition with complete dropped-slot layout - -Never guess `valid_from` from commit time when same-xact rows precede commit. If -parser can identify changed relation but not exact change position, publish -post-commit version for future rows and fail closed for overlapping rows - -### Catalog frontier and decode admission - -Pump owns monotonic catalog frontier: highest dispatched WAL position with no -unresolved committed catalog boundary before it. Publication hold advances -frontier through catalog commit only after journal batch becomes durable - -Record dispatch after catalog boundary therefore proves required history is -present. Handle records inside catalog-changing xact separately: - -- first catalog record marks top xid catalog-dirty before later user record can - decode -- `XLOG_SMGR_CREATE` marks new rfn immediately -- user heap record after dirty mark stays `SpillEntry::Raw`, even when old - descriptor exists for same rfn -- commit drain waits for catalog frontier through commit `next_lsn`, then - selects captured interval and decodes raw records -- user record before first catalog mutation may decode against predecessor - interval -- abort clears dirty state and raw records without journal append - -When relation identity for dirty catalog records is not known yet, defer all -later user heap records in that top xid. Prefer extra raw buffering over stale -decode. Capture batch later narrows affected relations; unrelated deferred rows -decode through unchanged intervals - -Multiple catalog changes after dirty mark may expose intermediate layout which -final snapshot cannot recover. Compatibility check or ambiguity record decides, -never timing or cache state - -## Catalog-boundary detection - -Track catalog mutation by top xid in pump classifier, including assigned -subxids. Existing `CatalogTracker` and `pg_class_decoder` provide starting -signals, but capture admission must not depend on decoder-worker cache state - -Build affected-relation observations from: - -- relcache invalidation messages in commit records carrying `HAS_INVALS` -- `XLOG_XACT_INVALIDATIONS` records -- decoded `pg_class` identities already available to `CatalogTracker` -- relation identifiers from other catalog tuples needed for exact change - position, especially `pg_attribute` and `pg_index` -- `XLOG_SMGR_CREATE` markers for new filenode generations -- `XLOG_SMGR_TRUNCATE` and heap `TRUNCATE` OIDs where relevant - -Decode `SharedInvalidationMessage` with explicit PostgreSQL-major layouts. -Unknown tag, short payload, unsupported major, or unresolved database identity -must never mean no change - -Fallback for incomplete affected-rel enumeration: - -1. capture all catalog-visible user relations in affected database -2. diff against durable preceding snapshot to find changes and drops -3. retain conservative post-commit versions -4. fail closed for user records in any interval whose exact `valid_from` cannot - be proved - -Full scan is acceptable as rare DDL-rate fallback. It preserves future rows -without claiming correctness for ambiguous intra-xact history - -## Publication hold - -At each catalog-mutating top-level commit: - -1. register result-bearing boundary waiter before commit can leave pump -2. forward commit bytes through `next_lsn` to shadow -3. enqueue commit for decoder path -4. force `QueueingRecordSink::flush()` so commit cannot remain stranded inside - current source chunk -5. wait for shadow replay through exact `next_lsn` -6. capture affected descriptors on dedicated shadow connection -7. append one checksummed journal batch, fsync data, atomically advance manifest - frontier, fsync directory -8. release successor-byte publication on `Ok` -9. terminate pump on error - -Waiter selects between capture result and terminal transport or worker-health -signal. Channel closure, worker panic, replay timeout, catalog error, journal -error, manifest error, and permanent walreceiver loss wake waiter with `Err` - -Do not wait for decoder to process commit. Forced flush proves delivery to -queue, health signal proves task remains viable. Capture lane never calls -ClickHouse and never queues behind `flush_due_retires` - -Crash behavior follows hold position: - -- before journal fsync, successor bytes were not published, restart replays and - recaptures boundary -- after journal fsync but before release, duplicate capture is idempotent by - source identity, timeline, commit `next_lsn`, and batch digest -- after release, durable batch already covers every published successor record -- partial tail without valid batch footer or checksum is ignored, then same - boundary is recaptured before publication resumes - -### Live wire requirement - -Whole archive segments cannot stop after a mid-segment commit and later append -original successor bytes. Catalog-boundary hold therefore requires active -walreceiver, while archive remains post-release recovery path - -Enforce capability, not flag value: - -- reject `--walsender-connect-timeout=0` -- fail startup if walreceiver does not attach before configured timeout -- during hold, reconnect live wire before catalog timeout or fail boundary -- prevent `restore_command` from observing segment containing unreleased bytes - -Positive timeout without attachment must fail startup, not warn and continue -archive-only - -## Durable metadata journal - -Store one append-only journal beside cursor state. Journal header binds data to: - -- source system identifier -- PostgreSQL major -- timeline -- source database OID -- WAL segment size -- format version - -Reject source replacement, incompatible major, timeline mismatch without -declared history transition, or checksum failure. Never load files solely by -xid or filenode - -Each catalog batch contains: - -- commit start and `next_lsn` -- affected relation observations and trigger positions -- complete new descriptor versions -- explicit drop versions -- old and new descriptor digests -- deterministic schema intent derived from durable predecessor -- toast main/relation ownership intervals -- ambiguity records for intervals that must fail closed -- batch checksum and completion footer - -Key descriptor lookup by `(source, timeline, db_node, spc_node, rel_node)` and -search version intervals by WAL position. Retain relation OID as identity and -lineage field, not lookup substitute, because filenodes rotate - -Schema intent must not depend on volatile `ShadowCatalog::prev_known`. -Derive `EnsureRelation`, `Changed`, or `Dropped` from preceding journal version -and descriptor digest. Replaying journal produces same event at same -`valid_from` position - -Config events may share journal framing so compaction can checkpoint one ordered -metadata state. They still originate only from WAL-decoded config rows - -### Capture snapshot - -Use one SQL statement or explicit read-only repeatable-read transaction after -shadow reaches boundary. Batched capture must include: - -- `pg_class`, `pg_namespace`, and relation identity -- every positive `pg_attribute.attnum`, including dropped positions -- type metadata needed for physical decode -- `pg_index` data for primary and replica-identity keys -- relation persistence and relkind -- toast ownership in both directions - -Preserve foreign-database guard before rfn lookup. `rel_node` is unique only -inside database identity - -Query dropped attributes without `pg_type` inner join. PostgreSQL can zero -`atttypid` while retaining physical `attlen` and `attalign`; descriptor must -keep one slot per positive attnum and consume bytes for dropped positions - -Serialize complete versioned `RelDescriptor` and `RelAttr`, including: - -- namespace, relation name, OID, rfn, relkind, persistence -- attnum, name, dropped flag, nullability, missing value -- type OID/name, length, alignment, by-value, storage, typmod -- replica identity mode, index OID, key attnums -- toast relation identity - -Do not reconstruct any field from live catalog downstream - -### Bootstrap and migration - -Greenfield bootstrap captures complete catalog baseline at bootstrap handoff -LSN before streaming starts. Decoder never requests history before that point - -Existing deployments enabling catalog history need one explicit transition: - -- drain to durable boundary, capture complete baseline, start new history epoch -- or resnapshot - -Do not seed current descriptors and silently claim coverage for earlier replay -range. Explicit `--start-lsn` before retained baseline must fail startup - -## One manifest and one GC cut - -Replace independent retention formulas with one crash-safe manifest and one -shared helper used by startup and pruning. Manifest owns at least: - -```text -source identity -timeline -effective durable resume LSN -decoder floor -catalog frontier -shadow recovery floor -GC cut -metadata checkpoint generation -immutable routing seed digest and contents -``` - -Compute effective resume with exact startup rules, including segment alignment, -cursor `emitter_ack_lsn`, `filter_durable_lsn`, sealed-archive clamp, bootstrap -handoff, and explicit override validation. Pruner must call same helper rather -than duplicate formula - -Compute one conservative cut from every durable floor. Apply cut to artifact -families with format-specific mechanics only: - -- archive and spool segments remove complete units ending at or below cut -- xid spill removes only transactions proven below cut and no longer live -- toast-retire entries compact below cut -- metadata journal compacts old batches into checkpoint, retaining predecessor - descriptor/config version needed to answer first position at cut - -Persist new checkpoint and manifest before deleting old journal segments. -Crash between manifest fsync, deletion, and directory fsync must load either -old complete generation or new complete generation - -Required archive-clamp case: - -1. cursor ack reaches segment `N+2` -2. sealed archive ends at `N` -3. metadata version needed by replay lies in `N+1` -4. GC runs -5. restart clamps to archive end -6. version remains available and replay succeeds - -Reject operator rewind below GC cut. Never treat missing history as cache miss -eligible for current-state lookup - -## Descriptor and route stages - -Split row context at actual ordering boundary: - -```rust -struct DescribedHeap { - decoded: DecodedHeap, - descriptor: Arc, -} - -struct RouteSnapshot { - mapping: Arc, - column_overrides: Arc, - row_encoding: Arc, -} - -struct RoutedHeap { - described: DescribedHeap, - route: Arc, -} -``` - -`MergedDrain` decodes raw records with catalog history and yields -`DescribedHeap`. Reorder coordinator then: - -1. applies preceding schema and config entries -2. resolves route for following WAL interval -3. snapshots every encoding-relevant mapping field -4. constructs `RoutedHeap` -5. dispatches trailing segment - -Route must include column overrides consumed by `TablePlan`, namespace defaults, -auto-create result, destination, and any other state capable of changing row -values or uncompressed CH row encoding. `TableMapping` alone is insufficient - -Detoast and physical tuple decode use `DescribedHeap`. Encoder plan uses -`RoutedHeap`. No stage performs another catalog or live-mapping lookup - -Account retained descriptors and route snapshots in spill, queued-batch, and -resident-memory budgets - -## Routing and config clock - -Make routing and shape config pure function of WAL position: - -- persist immutable TOML/CLI routing seed in manifest when history epoch starts -- reject changed routing seed on restart until operator starts new drained epoch - or resnapshot -- apply table, namespace, column, mapping, and shape changes only through - WAL-ordered config overlay after pump starts -- limit SIGHUP to operational knobs whose value cannot change logical row - contents, schema, or destination, such as budgets, compression, retry, and - observability -- apply each WAL config entry through same reorder site as schema entry - -Boot source-table seed must correspond to baseline LSN and become durable before -streaming. Later source config changes come only from decoded WAL, never fresh -side query on restart - -Append committed config events to metadata journal before first event from that -xact applies. Config-only commits need no shadow publication hold: stable boot -seed plus retained source WAL can reproduce append after crash. Identify event -by source, timeline, commit LSN, record LSN, and ordinal, not xid alone - -Journal compaction folds config events below GC cut into checkpointed config -state. Replayed event append is idempotent and must match stored digest - -This removes per-commit config-version references. Route at `L` derives from -durable seed plus ordered config entries through `L` - -## Replay and Oracle policy - -Keep byte-identical replay invariant. `_lsn` is ReplacingMergeTree version, but -equal-version rows with different bodies do not have a safe deterministic -winner. Dedup-sufficiency therefore cannot permit value drift - -WAL row decode may use only deterministic in-process codecs. `PgPending`, -`Unsupported`, compressed varlena without local decoder, or any value requiring -live `walshadow_decode_disk` fails closed before row dispatch - -Remove placeholder fallback from replayable WAL path. Oracle may remain for -diagnostics or explicitly non-replayable tooling, never for rows governed by -cursor rewind - -Expand local codecs independently. Do not persist per-value Oracle output in -metadata journal, that recreates per-row bundle state and couples catalog hold -to value volume - -## Raw substrate - -Complete both repairs before ordinary raw verdict is enabled: - -- spill v5 persists original xid in `RawRecord`; reconstructing record with - `xact_id = 0` corrupts `_xid` -- `MergedDrain` owns pending decoded-item queue because one - `XLOG_HEAP2_MULTI_INSERT` yields multiple heaps - -Queue every tuple from one raw record before merge advances beyond record LSN. -Preserve event-before-heap tie break at equal LSN and include queued decoded -memory in budget accounting - -Raw ordinary decode resolves descriptor from journal, produces -`DescribedHeap`, and enters same committed-tuple path as live decode. Do not -maintain separate stash emitter - -Resolve toast ownership before main tuple detoast for generations created in -same xact. Ownership comes from descriptor intervals, not current catalog or -per-commit bundle - -### TRUNCATE - -Existing-table `TRUNCATE` plus reload works when final new generation is -catalog-visible: old generation and truncate event come from history, SMGR -marker anchors new generation, final capture supplies new descriptor - -`CREATE; INSERT; TRUNCATE; INSERT` contains first generation never visible at a -commit boundary. Fail closed until explicit lineage/FPI work can prove first -rows are safely superseded. Do not revive discard-proof taxonomy in stash - -Preserve TRUNCATE record LSN and resolve OIDs through durable catalog history so -wipe barrier remains ordered before post-truncate rows - -### Image-only records - -Logical ordinary INSERT/COPY keeps tuple block data with -`REGBUF_KEEP_DATA`, including checkpoint-mid-COPY cases. True image-only source -is rewrite path using `HEAP_INSERT_NO_LOGICAL` - -Until main rewrite FPI admission lands, image-only ordinary record fails closed. -Synthetic image-only test must carry image without block data. Do not use -checkpoint-mid-COPY as proxy - -## One drain consumer - -Remove duplicate ordered apply implementations. Run serial -`XactRecordSink` behavior as degenerate pipeline configuration with one worker -and synchronous observer. Keep one merge, one schema/config apply site, one -route snapshot point, and one barrier implementation - -Metrics-only and test paths use same ordering engine. Do not preserve alternate -catalog-event semantics for convenience - -## Fail-closed boundary - -Stop before any partial xact emit when: - -- descriptor interval is absent or ambiguous -- generation never became catalog-visible -- affected-relation invalidation cannot be decoded safely -- descriptor journal or manifest is missing, corrupt, or source-mismatched -- dropped-column physical metadata is incomplete -- operation has image only -- relation is mapped materialized view awaiting replacement semantics -- deterministic codec is unavailable -- explicit rewind precedes retained baseline - -Abort and subxact abort discard buffered raw records, observations, and pending -metadata events. Never publish descriptor from aborted catalog transaction - -Unmapped relation with complete descriptor remains normal route discard at -record position. Missing descriptor is not equivalent to unmapped - -## Delivery order - -Treat order as dependencies, not independently shippable row features - -1. unify manifest and effective-resume/GC helper -2. collapse serial drain into pipeline ordering path -3. add exact `next_lsn`, boundary hold, worker health propagation, and active - walreceiver enforcement without changing row verdicts -4. add journal format, bootstrap seed, affected-rel capture, dropped-slot - fidelity, source validation, compaction, and crash recovery -5. switch all live descriptor lookup and schema events to journal, remove - generation epoch, lazy invalidation, `prev_known`, and volatile schema-event - channel -6. persist routing seed, constrain SIGHUP, journal WAL config, add staged - `DescribedHeap`/`RoutedHeap`, and enforce deterministic codec policy -7. add spill v5 xid and multi-insert pending queue -8. atomically enable ordinary raw `INSERT`, `MULTI_INSERT`, `UPDATE`, - `HOT_UPDATE`, and `DELETE` -9. generalize stash metrics and documentation -10. later, admit rewrite FPI to cover never-visible generations - -Keep ordinary raw decode behind one feature gate until steps 1 through 7 pass. -Do not enable insert family before descriptor fidelity, config ordering, and -fail-closed policy - -## Acceptance - -### EndRecPtr and hold - -- unaligned single-page record computes PostgreSQL `EndRecPtr` -- page-spanning and segment-spanning records compute exact next position -- record ending on page boundary remains correct -- `XLOG_SWITCH` advances to segment end -- replay equal to exact commit `next_lsn` permits capture -- decoder batch size greater than one, commit mid-CopyData chunk, forced flush - prevents deadlock -- capture error, journal error, worker error, worker panic, channel closure, and - walreceiver loss wake waiter with `Err` -- positive walreceiver timeout with no attachment fails startup -- DML-only commit does not park -- bare DDL with no replicated rows parks once and releases after durable capture - -### Catalog history - -- existing table, cold cache, pre-`ALTER` row, later same-rfn `ALTER`, forced - worker lag, old row uses old descriptor -- same sequence after restart and cache eviction produces identical row bytes -- CREATE plus plain/toasted COPY captures descriptor at marker and emits rows -- existing-table TRUNCATE plus reload resolves final generation -- relation rename, replica-identity change, and column override bind to correct - WAL interval -- dropped column retains physical slot and subsequent values decode correctly -- batched snapshot preserves primary and replica-identity index attnums -- foreign database rfn never resolves through local database query -- schema `Ensure`/`Changed`/`Dropped` event replays from journal without - `prev_known` -- multiple incompatible layouts inside one xact fail closed when intermediate - shape was never captured -- unknown invalidation tag triggers full-scan fallback or fail closed, never - false no-change - -### Raw operations - -- CREATE, INSERT, UPDATE, COMMIT emits final row with source xid -- CREATE, INSERT, DELETE, COMMIT emits ordered tombstone -- TRUNCATE, INSERT, UPDATE, COMMIT preserves wipe ordering -- multi-insert emits every tuple before merge advances -- multi-insert followed by later update preserves LSN order -- replica identity default, full, and index changes decode old image correctly -- subxact update/delete followed by subxact abort emits nothing from aborted - branch -- unsupported no-payload heap operation remains explicit fail-closed result - -### Routing - -- unmapped CREATE plus INSERT discards at route stage, counted by reason -- config-table opt-in before row in same xact routes row -- row before opt-in discards, trailing row routes -- auto-create schema event applies before route snapshot -- route snapshot includes column override consumed by `TablePlan` -- restart with changed TOML mapping seed refuses stream until new epoch -- SIGHUP operational knob applies, SIGHUP mapping/shape mutation is rejected - -### Replay codecs - -- locally decoded values replay byte-identically after crash -- `jsonb`, array, domain, custom type, and compressed varlena without local codec - fail closed before any xact row dispatch -- shadow extension present, absent, changed, or injected Oracle error cannot alter - WAL output because WAL path never queries Oracle - -### Durability and GC - -- crash before batch footer ignores tail and recaptures boundary -- crash after journal fsync but before release accepts idempotent duplicate -- crash after release reloads descriptor and schema event from journal -- checksum, unknown version, source-system mismatch, and timeline mismatch fail - closed -- cursor in `N+2`, archive end in `N`, required metadata in `N+1`, GC then - restart retains metadata and replays successfully -- crash before and after checkpoint rename, manifest fsync, old-segment delete, - and directory fsync always loads one complete generation -- explicit start LSN before baseline or GC cut is rejected - -### Fail closed - -- markerless replicated generation without history -- CREATE, INSERT, TRUNCATE, INSERT where first generation never becomes visible -- TRUNCATE, reload, rewriting ALTER where intermediate generation disappears -- mapped materialized-view refresh -- synthetic image-only ordinary record - -## Observability - -- `catalog_boundary_holds_total{result}` and hold duration -- walreceiver attachment and reconnect state -- capture rows, full-scan fallbacks, snapshot duration, affected-rel count -- journal frontier, versions, bytes, checkpoint generation, and GC cut -- descriptor lookups by hit, missing, ambiguous, corrupt, and source mismatch -- schema events by deterministic intent -- fail-closed rows and xacts by reason -- raw decoded records and rows by operation -- pending multi-insert rows and bytes -- manifest effective resume, archive clamp, decoder floor, and shadow recovery - floor - -Rename remaining `toast_stash_*` metrics only after generic path lands. Keep -toast-specific counters where behavior remains toast-only - -## Removed mechanisms - -Delete after journal path becomes authoritative: - -- per-commit `ResolutionBundle` -- `FenceRequest` keyed by top xid -- bundle resolver lane, bundle loader, and bundle pruner -- stash discard-proof taxonomy -- live `relation_at` calls from row decode and detoast -- generation epoch and lazy descriptor eviction as correctness mechanisms -- volatile `prev_known` schema baseline -- route lookup inside decode worker -- serial ordered-event apply path - -Keep shadow catalog client only for boundary capture, bootstrap validation, and -operational inspection - -## Rejected - -- stash-only publication fence, leaves live cold-cache race intact -- per-commit bundles, input closure expands to routing, Oracle, schema baseline, - source identity, and lineage while adding another GC protocol -- top-xid bundle identity, xids wrap and do not identify source timeline or - commit -- current-state lookup plus descriptor ownership, preserves wrong initial - descriptor -- per-record shadow lockstep, correct but serializes decoder behind libpq and - defeats queued pump -- catalog tuple reconstruction from WAL, duplicates MVCC visibility and - version-sensitive catalog layout; boundary sampling lets PostgreSQL interpret - its own catalog -- current PG catalog time travel, standby exposes moving current state and - cannot retain arbitrary historic snapshots -- non-WAL mapping reload during streaming, route becomes wall-clock dependent -- attach route before ordered events apply, cannot represent auto-create or - mid-xact opt-in -- weaken replay to dedup-sufficiency, equal `_lsn` with different bodies has no - safe deterministic winner -- persist per-value Oracle output, creates row-volume durability and capture - coupling -- discard never-visible generation based on marker inference, marker proves - observation start but not identity or lineage -- independent artifact retention formulas, startup archive clamp already proves - they diverge -- archive-only boundary hold, whole segment publication cannot stop at commit - -## Future consumers - -- rewrite FPI walk can add descriptor-backed tuples for generations never - visible at commit and retire largest fail-closed class -- [two_phase_commit.md](two_phase_commit.md) can persist PREPARE buffers while - reusing catalog history unchanged -- audit tooling can inspect relation shape and routing state at any retained WAL - position diff --git a/plans/ops.md b/plans/ops.md index 6962f7fc..cd68fbd0 100644 --- a/plans/ops.md +++ b/plans/ops.md @@ -79,8 +79,17 @@ Inventory by category: - `walshadow_xacts_{committed,aborted}_total` - `walshadow_decoder_{decoded,partial,toast_chunks,toast_malformed}_total` - `walshadow_emitter_{rows,blocks,xacts,unsupported_relations}_total` -- `walshadow_decode_{resolved,fallback_raw,validate_sampled,validate_mismatches,errors}_total` +- `walshadow_decode_{resolved,fallback_raw,errors}_total` (oracle path, see [shadow.md](shadow.md)) +- commit-time stash ([xact.md](xact.md)): + `walshadow_raw_stash_records_total{kind=dirty|marker,op}` admissions, + `walshadow_raw_stash_deferred_total`, + `walshadow_raw_stash_bytes{storage=mem|spill}`, + `walshadow_raw_decode_records_total{kind=toast|ordinary,op}` + + `walshadow_raw_decode_rows_total{op}` decode at commit resolution, + `walshadow_raw_pending_{rows,bytes}` fanout gauges, + `walshadow_stash_foreign_db_skipped_total` per-filenode foreign-db + discards - `walshadow_spill_evictions_total`, `walshadow_uptime_seconds` ### Buffer gauges diff --git a/plans/oracle.md b/plans/oracle.md index d6a9c4ae..1c2a6b68 100644 --- a/plans/oracle.md +++ b/plans/oracle.md @@ -1,4 +1,4 @@ -# oracle — differential decode oracle for Tier 3 types +# oracle — PgPending resolver for Tier 3 types [`src/oracle.rs`](../src/oracle.rs) plus [`pgext/`](../pgext/) @@ -13,10 +13,12 @@ doesn't reimplement. Ship known-stable types in-tree, route everything else through shadow-PG bridge calling same `typoutput` PG itself would call -Validation half is symmetric. Take walshadow's locally-decoded text, -hand raw on-disk bytes to shadow PG via same bridge, diff. Mismatch -surfaces decoder regression on first sampled row of that type, not -after bad data has settled in CH +Resolution is best-effort by policy: the oracle resolves post-plan, so +its answer reflects shadow's catalog state at resolve time, which may +lag the row's own catalog interval in DDL edge cases — accepted in +exchange for mostly supporting unknown types. Unresolved `PgPending` +ships raw on-disk bytes; unresolved `Unsupported` stays the +fail-closed backstop at encode ## In-tree Tier 3 @@ -81,48 +83,18 @@ Installed into **shadow PG**; stays shadow-only. 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 with `unsupported_values++`. No +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 - -## --validate sampling - -Off by default (`rate == 0` short-circuits before any atomic op). -`walshadow-stream --validate ` probes 1-in-N tuples through -[`Sampler::pick`](../src/oracle.rs) via `AtomicU64::fetch_add(Relaxed)` -— lock-free, multi-worker safe - -Symmetric probe shape: - -1. reuse on-disk raw bytes for `PgPending` (in-tree Tier 3 values — - `Numeric`, `Inet` — discard raw bytes at decode, so today only - `PgPending` columns probe; full symmetric re-encode is open work) -2. `SELECT walshadow_decode_disk($1::oid, $2::bytea)` on shadow -3. compare returned text to `local_text` — empty for `PgPending` - (no local rendering exists), so the probe degrades to "did the - extension call succeed + return text" -4. bump `OracleStats.{probes, matches, mismatches, errors}` - -Mismatch is watchdog signal, not gate — row still ships to CH. First -sampled bad row of a regressed type surfaces in status line via -`OracleStats::summary` - -Making the probe genuinely differential needs `ColumnValue` Tier 3 -variants to retain source bytes alongside decoded form (or a -re-encoder per Tier 3 codec) — until then "validator caught a planted -regression" holds for `PgPending`-routed types, not in-tree Tier 3 - -Operator policy: `--validate 1000` is ~0.1% sampling, invisible against -shadow's existing catalog query load +rendering resumes. Stats surface as `walshadow_decode_{resolved, +fallback_raw,errors}_total` ## Pinning shadow locale `lc_numeric` and `lc_time` pinned at shadow bootstrap. Without this, `typoutput` on `numeric` and `interval` would diff against walshadow's -locale-independent rendering and validator would noise on deployments -running non-`C` locales. See [shadow.md](shadow.md) for bootstrap -surface +locale-independent rendering on deployments running non-`C` locales. +See [shadow.md](shadow.md) for bootstrap surface ## Cross-links diff --git a/plans/overview.md b/plans/overview.md index ae823d0c..c62fcaa5 100644 --- a/plans/overview.md +++ b/plans/overview.md @@ -80,9 +80,9 @@ Component docs live alongside this overview: `WalStream` page walker, `streaming_walker`, `QueueingRecordSink` decoupling pump from decoder, `decoder_sink` - [shadow.md](shadow.md) — shadow PG lifecycle (`materialize_conf`, - `standby.signal`, supervision), `ShadowCatalog` libpq cache with - generation counter + `relation_at` replay-LSN gate, per-relation - `SchemaEvent` channel feeding CH DDL applicator + `standby.signal`, supervision), `ShadowCatalog` libpq client feeding + descriptor capture ([desc_log.md](desc_log.md)) + name-keyed opt-in + resolution - [decoder.md](decoder.md) — `heap_decoder` Tier 1/2 type matrix, `MULTI_INSERT` fan-out, FPI decompression, `main_data` parsing, `pg_class_decoder` driving `CatalogTracker` @@ -96,7 +96,8 @@ Component docs live alongside this overview: - [config.md](config.md) + [add_table.md](add_table.md) — layered live config, source-PG overlay, per-table opt-in, and initial-load modes - [emitter.md](emitter.md) — parallel decode+insert pipeline - (`src/pipeline/`): reorder coordinator → decode pool ×M → + (`src/pipeline/`): reorder coordinator (side-effect-free transaction + plan, then execute) → decode pool ×M → `InsertBatcher` (seal complete INSERTs on deadline / row / byte budget) → inserter pool ×N → contiguous-done ack watermark; resident-payload permit pool bounding payload bytes across stages; @@ -112,10 +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) — differential decode oracle: re-encode + - `SELECT $1::bytea::::text` round-trip against shadow, - `--validate ` sampling, walshadow PG extension (`pgext/`) exposing - `walshadow_decode_disk(oid, bytea) -> text` for Tier 3 types +- [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 - [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 @@ -141,12 +141,13 @@ Component docs live alongside this overview: replica-identity key (PRIMARY KEY, `USING INDEX`, or `FULL`) on every replicated table, both preflighted. DELETE only needs the key to mark the row; `FULL` is accepted, not required -5. **Source DDL that rewrites a user table.** Ordering invariant: - shadow replay LSN ≥ decoder read LSN. `ShadowCatalog::relation_at` - blocks until `pg_last_wal_replay_lsn() >= commit_lsn` so decoder - reads post-DDL catalog for heap records. Fast-path `ADD COLUMN` - skips rewrite; read-time defaults via `attmissingval` cover - bootstrap-then-ALTER skew +5. **Source DDL that rewrites a user table.** Descriptor capture runs + inside the catalog-boundary hold with shadow applied exactly through + the commit's `next_lsn`; decode reads interval-scoped answers from + the durable descriptor log ([desc_log.md](desc_log.md)), so a record + always decodes against the shape that produced it. Fast-path + `ADD COLUMN` skips rewrite; read-time defaults via `attmissingval` + cover bootstrap-then-ALTER skew 6. **Shadow PG version skew.** Same major as source. Daemon refuses to start on mismatch or PG < 16 7. **Catalog cache invalidation granularity.** Single generation bumps @@ -187,10 +188,9 @@ Source pinned at `wal_level=logical` + a usable replica-identity key ### v1.1 -4. `--validate` catches a planted decoder regression on the first - sampled row of the bad type. **Live** via differential oracle + - `pgext/`; absent extension surfaces as `oracle fallback=N` and - raw-bytes pass-through for `PgPending` +4. Tier 3 types outside the local codec matrix reach CH as PG-rendered + text. **Live** via oracle + `pgext/`; absent extension surfaces as + `oracle fallback=N` and raw-bytes pass-through for `PgPending` 5. `kill -9` of walshadow mid-workload, restart, CH end-state matches non-interrupted run modulo merge transients. **Code-complete.** `tests/kill_restart.rs` exercises three kill strategies × five diff --git a/plans/shadow.md b/plans/shadow.md index 4d0050fb..1d825a55 100644 --- a/plans/shadow.md +++ b/plans/shadow.md @@ -14,9 +14,10 @@ Two surfaces: restore, recovery startup, supervision. `Shadow` in `src/catalog/shadow.rs` provides operations. Daemon owns full lifecycle whenever `--bootstrap-shadow-data-dir` is set -- **catalog API** — async libpq client + LRU + replay-LSN gate + - schema-event channel. Owned by `ShadowCatalog` in - `src/shadow_catalog.rs`. Consumed by heap decoder & CH DDL applicator +- **catalog API** — async libpq client: batched descriptor fetches for + capture ([desc_log.md](desc_log.md)), name-keyed resolution for opt-in + and backfill standup, replay-LSN gate. Owned by `ShadowCatalog` in + `src/shadow_catalog.rs`. Decode never queries it Lifecycle code is sync, shells out to PG binaries; catalog code is async, drives `tokio-postgres`. They share data dir & port but @@ -101,9 +102,9 @@ See [architecture/shadow_communication.dot](../architecture/shadow_communication for rendered diagram: 1. **libpq catalog queries** — `ShadowCatalog`'s tokio-postgres client. - One long-lived connection over unix socket for `relation_at` / - `relation_by_oid` / `wait_for_replay` / `sweep_dropped`. Hot path - for decoder + One long-lived connection over unix socket for descriptor capture's + batched fetches, name-keyed resolution, and `wait_for_replay`. + Boundary-rate, never per record: decode reads the descriptor log 2. **walsender wire** — `ShadowStreamSink` framing filtered-record bytes as `'w'` `XLogData` CopyData frames, listener accepts shadow's walreceiver (`primary_conninfo` in shadow's conf). Record-cadence @@ -125,63 +126,35 @@ error or end-of-WAL. Both feed shadow's startup recovery which advances Async libpq client over shadow's unix socket. Key surfaces: ```rust -pub async fn relation_at(&mut self, rfn: RelFileNode, at_lsn: u64) - -> Result>; -pub async fn relation_by_oid(&mut self, oid: Oid) - -> Result>; +pub async fn fetch_descriptors_batch(&mut self, oids: &[Oid]) + -> Result<(u64, Vec)>; // + replay position +pub async fn fetch_all_descriptors(&mut self) + -> Result<(u64, Vec)>; // capture-all / boot seed +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 fn invalidate(&mut self) -> u64; // bump generation -pub fn subscribe(&mut self) -> mpsc::UnboundedReceiver; ``` ![shadow](../architecture/shadow.svg) -Cache: `by_filenode` + `by_oid` HashMaps over -`CacheEntry { generation, desc }`, capped at `max_entries` (default -4096), FIFO-evicted via `EvictionIndex` BTreeMap (O(log n) victim -select via `BTreeMap::pop_first`). Hits short-circuit before any I/O; -misses go through one SQL fan-out (`pg_class` + `pg_namespace` + -`pg_index` for replident + `pg_attribute` + `pg_type`). Filenode -resolution goes through `pg_relation_filenode(oid)` so mapped catalogs -(`pg_class`, `pg_attribute`, shared catalogs in `global/`) & regular -tables resolve uniformly. `at_lsn = 0` skips replay gate — used when -caller proved freshness or cache is driven from non-WAL source -(bootstrap seed, tests). Post-`wait_for_replay` re-drain is -load-bearing: DDL observed concurrently during the yield can have -bumped the epoch - -Foreign-DB filenodes are rejected before the SQL fan-out: -`fetch_by_filenode` returns `CatalogError::ForeignDatabase` when -`rfn.db_node` is neither the connected shadow DB nor `0` (shared -catalog). Physical replication ships the whole cluster's WAL, and -relfilenodes are unique only within a database — a foreign `db_node`'s -`rel_node` can collide with a real local relation and resolve to the -wrong descriptor (`spc_node` need not match). The connected DB OID is -memoized in `current_db_oid` (fixed by `conninfo`, survives reconnect); -`foreign_db_skips` counts the rejections. Emitter folds the error into -a row skip (see [emitter.md](emitter.md)) - -## Catalog invalidation - -Single `u64` generation counter; lazy eviction keeps `invalidate()` -O(1) regardless of cache size. Coarse-fire by design — see project -memory note on PG version WAL skew: any `pg_class` write triggers -invalidation, including hint-bit writes & autovacuum noise that don't -change schema. Decoder fidelity does not imply cache freshness; coarse -predicate over-invalidates but cannot under-invalidate. Fast-path -`rfn-may-be-stale` predicate deferred — streaming-fed shadow makes -post-bump refetch ms-cadence (see -[future/risks.md](future/risks.md)) - -DROP discovery is xid-keyed, not counter-keyed -(`catalog_tracker::PendingSweeps`): the decoder worker arms the xid of -each pg_class heap_delete record, commit sinks disarm + run -`sweep_dropped` only at that xact's own commit. A counter consumed at -whatever commit drained first swept while the dying tuple was still -MVCC-alive in shadow (interleaved commit before the DROP's commit -replayed) and lost the `Dropped` event. ADD COLUMN / CREATE INDEX -never arm, so pgbench-rate commits skip the SQL probe entirely; -aborts disarm without sweeping +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 +the boundary's `next_lsn`, so the snapshot is exactly the commit's +state. Foreign-db rejection likewise moved to the log's lookup surface +(`LookupResult::ForeignDb`). DROP discovery is capture-native: an oid +absent from a boundary's fetch with a Present predecessor tombstones + +emits `Dropped` — no polling sweep ## Reconnect resilience @@ -263,34 +236,27 @@ Segment cadence preserved on top of record cadence: `DirSegmentSink` still writes one 16 MiB segment + manifest per boundary. Wire is hot path, segments are archive fallback + durable artifact -## SchemaEvent channel +## SchemaEvents -`ShadowCatalog::subscribe() -> mpsc::UnboundedReceiver`. -Variants (see diagram legend for trigger → DDL mapping): +Produced solely by descriptor capture as log diffs +([desc_log.md](desc_log.md)); they enter the xact buffer as drain +entries keyed `(drain_xid, valid_from)` and apply inside the reorder +barrier. Variants (see diagram legend for trigger → DDL mapping): -- `Added { desc }` — first sight of oid +- `Added { desc }` — no log predecessor (CREATE, or a rel entering an + existing log via capture-all discovery); boot re-applies `Added` for + the active Present set each start (idempotent CH DDL) - `Changed { old, new, diff: SchemaDiff }` — `SchemaDiff` carries `added_columns`, `dropped_columns`, `renamed_columns`, `type_changes`. Renames detected by attnum-match + name-diff heuristic; PG's `RENAME COLUMN` keeps attnum intact, natural case lands here -- `Dropped { oid, rel_name }` — `emit_dropped(oid)` from - `pg_class_decoder` heap_delete branch, or `sweep_dropped` poll (at - the dropping xact's commit, `PendingSweeps`-gated) for catalogs with - `relreplident = 'n'` where heap_delete carries no old tuple to - extract oid from - -`prev_known: HashMap>` retains last-seen -descriptors across generation bumps so diff source-of-truth survives -invalidation. Channel consumed by CH DDL applicator inside worker task -— see [emitter.md](emitter.md) +- `Dropped { oid, rel_name }` — oid absent from a boundary's capture + with a Present predecessor; works for `relreplident = 'n'` catalogs + too (no old-tuple decode needed — enumeration comes from commit-record + relcache invals) ## Namespace mapping gaps -Channel is **unbounded** (`mpsc::unbounded_channel`): the applicator runs in -the same worker task as the decoder, so back-pressure surfaces naturally as -task-local await depth rather than channel saturation, and a bounded -`mpsc::channel(64)` would buy nothing. - `NamespaceMapping` ([src/ch_emitter.rs](../src/ch_emitter.rs)) carries `auto_create`, `target_database`, and `drop_table_strategy` (the latter two resolved per-namespace in `DdlApplicator`); `type_overrides`, @@ -345,7 +311,7 @@ backfill, net-new knobs) is - [bootstrap.md](bootstrap.md) — initdb vs `BASE_BACKUP`, `apply_schema_dump` consumer, `seed_from_source` bootstrap fan-out -- [decoder.md](decoder.md) — `relation_at` consumer, heap-tuple decode +- [decoder.md](decoder.md) — descriptor-log consumer, heap-tuple decode against `RelDescriptor` - [emitter.md](emitter.md) — `SchemaEvent` channel consumer (`ch_ddl::DdlApplicator`), barrier-fence ordering diff --git a/plans/source.md b/plans/source.md index 38742757..aebf0437 100644 --- a/plans/source.md +++ b/plans/source.md @@ -130,6 +130,20 @@ when walker buffer crosses 16 MiB; records straddling the boundary hold the flush until the spanning record completes, rewrite then lands uniformly across both segs +Each `Record` carries two positions. `source_lsn` is record start — row +version and ordering key. `next_lsn` is PG `XLogReaderState::EndRecPtr` +(xlogreader.c arithmetic: single-page advances `MAXALIGN(xl_tot_len)`; +page/segment-spanning advances last continuation page's data start + +`MAXALIGN(rem_len)`; `XLOG_SWITCH` extends to segment end) — the value +`pg_last_wal_replay_lsn()` reports once shadow applies the record, so +every replay comparison uses it, never the last physical wire byte. +`Filter::decide_record` additionally stamps `catalog_boundary` on +commit records of catalog-mutating xacts: the filter marks xids dirty +when a record touches a catalog relation (bootstrap `< 16384` rule, +tracker-promoted filenodes, relmap updates) and drains marks at that +xact's commit/abort — commit record subxact lists and prepared xids +included, aborts clear without a boundary + Poisoned flag: any error returned to `push` (sink Err, walker BadPageMagic, parse failure, rewrite failure) sets `self.poisoned = true`; subsequent `push` calls short-circuit with @@ -245,6 +259,45 @@ Serial path: xact buffer caps the nudge at the observer's `idle_ack_ceiling(lsn)`. Without the idle advance source's slot pins WAL at last COMMIT, kill-restart idle catchup never resolves +## Catalog-boundary publication hold + +[`src/boundary_hold.rs`](../src/boundary_hold.rs). At a +`catalog_boundary` commit the pump must not publish successor bytes +until shadow replays through the commit's `next_lsn` — the seam +descriptor capture samples shadow at ([desc_log.md](desc_log.md)) + +[`BoundaryHoldSink`](../src/boundary_hold.rs) wraps +`QueueingRecordSink` in the daemon's sink chain and blocks inside +`on_record`: `WalStream` dispatches wire bytes for record N, then +awaits the record sink before framing N+1, so parking there withholds +every successor byte from both the shadow wire and the archive segment +sink (segment flush follows record drain; `restore_command` never +observes unreleased bytes). At the boundary it first force-flushes the +pump-side batch so the commit cannot strand in the accumulator, then +parks in [`CatalogBoundaryGate::hold`](../src/boundary_hold.rs) until +the aggregate walreceiver apply LSN reaches `next_lsn`. DML-only +commits never park; hold cost is DDL-rate + +Shadow keeps applying while the pump parks: the walsender listener +flushes already-queued frames on its own task. Walreceivers report +apply progress only when flush advances or on +`wal_receiver_status_interval`, so the gate prods each poll tick with a +reply-requested `'k'` keepalive (`ShadowStreamState::request_status`) +and observes apply at poll cadence (ms). Waiter is result-bearing: +decoder-worker death (`QueueingRecordSink::worker_alive`, channel +closed on fatal error or panic; parked root cause preferred over the +generic hold error), walreceiver loss past the deadline, and +`--catalog-hold-timeout` expiry (default 30s, keep under source's +`wal_sender_timeout`) wake it with `Err`, poisoning the stream and +terminating the pump. Walreceiver loss mid-hold is tolerated until the +deadline — a reconnect backfills the in-progress segment from +`wire_buf` and replay resumes. Never waits on ClickHouse, committed +drains, or queued barrier work — only shadow replay of shipped bytes + +Metrics: `walshadow_catalog_boundary_holds_total`, +`_hold_failures_total`, `_hold_seconds_total`; each release logs an +info line with held duration at target `walshadow::boundary_hold` + ## DecoderSink Per-record decoder dispatch lives in @@ -265,9 +318,8 @@ routed into xact buffer's chunk slot, distinct from `inserts` for user-table writes); `toast_chunks_malformed` (TOAST inserts decoder couldn't reinterpret as chunk — missing `chunk_id`/`seq`/`data`, type mismatch — surfaces corrupt catalog as counter, not silent loss); -`catalog_not_found` (heap record on relation `relation_at` couldn't -resolve, counted not retried — xact buffer reordering handles -legitimate races); `skipped_no_block` (`record.parsed.blocks` empty: +`catalog_not_found` (heap record whose descriptor-log lookup answered +a tombstone / uncovered interval, counted not retried); `skipped_no_block` (`record.parsed.blocks` empty: LOCK, INPLACE, TRUNCATE) `on_xact_end` signature: @@ -378,7 +430,11 @@ colocated containers. `--walsender-bind` picks address; port-0 + barrier on `--walsender-connect-timeout` waits for shadow's walreceiver to attach before pump starts — `ShadowStreamSink::on_wire_chunk` drops bytes pushed before any connection registers, missed gap is -unrecoverable +unrecoverable. Attachment is a startup requirement, not best-effort: +zero timeout is rejected and expiry without a connection fails boot. +Catalog-boundary holds need a live wire — whole archive segments cannot +stop publication at a mid-segment commit, so archive-only operation is +not startable Sink: `ShadowStreamSink` composes via [`WalStream::set_bytes_sink`](../src/wal_stream.rs). Per-connection diff --git a/plans/xact.md b/plans/xact.md index 14a17a7c..e8ed5e5a 100644 --- a/plans/xact.md +++ b/plans/xact.md @@ -22,10 +22,9 @@ Three responsibilities collapse into one struct: - subxact tracker hinting early eviction & funneling commit drain across `top + subxids` -Catalog access is lazy: only heaps where any column is -`ColumnValue::ExternalToast` hit `ShadowCatalog::relation_at`, and only -at drain. No per-xact descriptor cache — it would duplicate shadow's -own LRU surface +Descriptor access is a wait-free interval lookup against the durable +log ([desc_log.md](desc_log.md)); heaps with `ColumnValue::ExternalToast` +resolve their owner descriptor at drain the same way ![xact](../architecture/xact.svg) @@ -43,7 +42,7 @@ XactState { spill: Option, // None until first eviction spill_bytes, // mirrors writer.byte_count() events: Vec<(u64, DrainEntry)>, // catalog/config/toast barriers - stash_rfns: HashSet, // commit-time resolution set + stash_rfns: HashMap, // commit-time resolution set } ``` @@ -108,22 +107,71 @@ status line ## Commit-time stash -Records on a filenode `relation_at` cannot resolve at record time (the -creating xact's pg_class row is MVCC-invisible until commit: rewrite -generations, same-xact CREATE/TRUNCATE + INSERT) ride the same per-xid -spill as `SpillEntry::Raw` — raw rmgr/info + main data + blocks with -images + record LSN — so subxact merge ordering and abort discard come -for free. Admission is gated on the filenode's `XLOG_SMGR_CREATE` -marker (observed pre-route-gate, global by filenode since the record -can precede xid assignment); once a filenode is a stash candidate the -per-record replay-gated lookup is skipped — the xact's own records can -never resolve. At commit `resolve_stash` resolves each candidate with -`relation_at(rfn, commit_lsn)`, installs per-filenode verdicts the -drain merge consumes (toast → decode like live chunks, ordinary heap → -fenced skip, unresolvable → counted discard), and queues a -`DrainEntry::ToastBarrier` at commit LSN per marker-proven toast -generation ([TOAST.md](TOAST.md)). Lifting the ordinary-heap fence: -[future/xact_stash.md](future/xact_stash.md) +Records the decoder cannot safely decode at record time ride the same +per-xid spill as `SpillEntry::Raw` — raw rmgr/info + main data + blocks +with images + writer xid + record LSN — so subxact merge ordering and +abort discard come for free. Three admission gates, in order: + +- `defer_catalog_decode` from the filter's dirty tree + ([filter.md](filter.md)): once a xact family touches the catalog, + every subsequent ordinary heap record in that family stashes raw — + a Present predecessor descriptor doesn't prove decodability inside + the dirty interval. Blocks that carry their own data drop their block + image here: ordinary raw decode reads data only, and the FPI-restore + path (rewrite-path toast chunks) needs an image *without* data, so + keeping both would spill a dirty COPY's whole WAL volume +- known-invisible filenode: `XLOG_SMGR_CREATE` marker (observed + pre-route-gate, global by filenode since the record can precede xid + assignment) or a prior stash on the same rfn; the xact's own records + can never resolve, so the per-record lookup is skipped +- spanned lookup miss: `descriptor_at_spanned(rfn, lsn)` answering + NotCovered past the coverage horizon or Dropped defers to commit-time + resolution; ForeignDb / pre-horizon NotCovered / Retired are counted + skips (rows can't outlive the commit). Ambiguous is reachable only on a + re-read starting past the dirty tree's first touch — marker-proven + filenodes defer, markerless ones fail closed rather than drop payload + +`StashMark` carries the filenode's lowest stashed LSN (the fence query's +lower bound) and whether any record on it was tracked payload-free. + +At commit `resolve_stash` resolves each candidate against the log at +the commit's `next_lsn` (capture ran inside the boundary hold, so the +batch is already indexed) and installs per-filenode `StashOutcome` +verdicts the drain merge consumes: + +- Present toast → `Toast(rel)`: decode stashed chunks like live ones; + marker-proven generation without its chunks fails closed + (`IncompleteToastGeneration`), and each proven generation queues a + `DrainEntry::ToastBarrier` at commit LSN ([TOAST.md](TOAST.md)) +- Present ordinary → `Ordinary { rel, valid_from, fence }`: raw records + re-run the heap decoder at drain under the commit-resolution descriptor, + fanning rows out through the merge's pending queue (MULTI_INSERT + yields per-tuple rows in tuple order, same-LSN events order before + the fanout); this is what delivers `BEGIN; CREATE TABLE; COPY; + COMMIT` rows +- `fence` is the filenode's ambiguity intervals overlapping + `[first stashed lsn, next_lsn)`. Resolution asks the log at `next_lsn`, + past every interval this commit published, so the verdict carries the + intervals and the drain fails closed per record whose own `source_lsn` + falls inside one (`XactBufferError::StashAmbiguous`): no descriptor + proven safe, neither decode nor discard is sound, operator takes a + fresh snapshot. A toast filenode with any overlap fails closed at + resolution — chunk layout is fixed, so an interval there is unmodelled +- draining stashed raws with no verdict installed fails closed + (`MissingStashResolution`); an aborted tree drops any verdict it had, so + a xid reused later never folds under a foreign descriptor +- Dropped / Retired / NotCovered → discard, end-state-neutral. + NotCovered with a marker means born + gone inside the xact family + (capture tombstones only predecessors; a commit-time survivor is + always Present), so no surviving rows +- ForeignDb → counted once per filenode + (`stash_foreign_db_skipped`) + +Raw decode at drain is bounded and deterministic: operation policy +rejects image-only INSERT/UPDATE (`FailClosedReason::ImageOnly`) and +unknown heap ops; DELETE decodes, LOCK/CONFIRM and Heap2 page +maintenance no-op. Per-op counters split live vs raw decode +(`raw_decode_{toast,ordinary,rows}_ops`, [ops.md](ops.md)) ## Subxact tracker @@ -157,9 +205,15 @@ gid (cstr, NUL term) if xinfo & HAS_GID // 1<<7 origin (8+8) if xinfo & HAS_ORIGIN // 1<<5 ``` -Short-read at any tail position degrades to -`XactCommitPayload::default()` (xact_time + no subxacts), so decoder -doesn't poison the stream over one bad record +Short-read at any tail position is a typed `XactPayloadError`. Filter +classification poisons on it (a silent partial parse could drop the +inval marking a boundary); decoder-side consumers degrade to +`XactCommitPayload::default()` (xact_time + no subxacts) so one bad +record doesn't poison the drain. Inval messages classify during the +walk: relcache invals enumerate affected rels, pg_namespace catcache / +whole-catalog invals mark capture-all ([desc_log.md](desc_log.md)); +`XLOG_XACT_INVALIDATIONS` (0x60, wal_level=logical command boundaries) +walks the same message encoding filter-side Standalone subxact rollback for a sub of a still-open top: top's pre-savepoint entries stay keyed on top_xid in `inflight` and flush at @@ -178,17 +232,24 @@ File layout: ```text [2 bytes "WS" magic = SPILL_MAGIC] -[u16 LE version = SPILL_VERSION = 4] +[u16 LE version = SPILL_VERSION = 6] repeating: [u8 tag] [u32 LE inner_len] [body of inner_len bytes] - tag=0 → SpillEntry::Heap (encoded DecodedHeap) + tag=0 → SpillEntry::Heap (u32 dict id + encoded DecodedHeap) tag=1 → SpillEntry::Chunk (encoded ToastChunk, TID + record LSN) tag=2 → SpillEntry::ToastDelete (TID-keyed, a store tombstone at drain) - tag=3 → SpillEntry::Raw (rmgr/info/main data/blocks + images) + tag=3 → SpillEntry::Raw (rmgr/info/main data/blocks + images + xid) + tag=4 → descriptor dictionary (u64 valid_from + descriptor) ``` +Heap bodies reference their descriptor by dictionary id; the writer +assigns ids in first-use order keyed on `(rfn, valid_from)` log +identity, the reader folds tag-4 records back into descriptors in +write order (they never surface as entries). One descriptor encodes +once per file however many heaps share it + `SpillReader::check_header()` runs lazily on first `next()`: rejects wrong magic with `SpillError::Format { offset: 0, detail: "bad magic …" }`, wrong version with same shape at offset 2. Reader is @@ -198,7 +259,9 @@ unrecoverable anyway `HeapOp` encodes as `0=Insert, 1=Update, 2=HotUpdate, 3=Delete, 4=Truncate`. v2 added `Truncate`; v3 added chunk TIDs + `ToastDelete`; -v4 added raw stashed records. +v4 added raw stashed records; v5 added the descriptor dictionary; v6 +added writer xid to heap + raw bodies (raw fanout stamps `_xid` from +the stashing subxact, not the draining top). Version mismatch is near-academic anyway: resume contract wipes spill dir on startup ([`SpillStore::clear`]) and manifest guarantees on-disk state is @@ -231,8 +294,11 @@ fresh config-surface decision One consumer exists for commit drain: the pipeline's reorder coordinator (`pipeline/reorder.rs`) pulls bounded `DrainedBatch` slices -from `CommittedDrain`, dispatching heaps to decode pool while applying -store rows and ordered barriers; ack collector tracks durability (see +from `CommittedDrain` and feeds them through the transaction planner — +plan first (side-effect-free: detoast, route, raw decode, all +input-derived failures surface here), then `execute_plan` replays the +sealed plan through barrier ordering to the decode pool and DDL +applicator; ack collector tracks durability (see [emitter.md](emitter.md)). Metrics-only runs use the same coordinator over a null tail; backup gap replay drives `drain_committed` through its own serial `ReplaySink`. Every consumer walks a slice via @@ -270,14 +336,12 @@ xact is in flight ## Drain entries and batch cursors -Catalog events arrive via `BufferingDecoderSink::drain_schema_events` -after every `relation_at` and via the reorder coordinator's -`route_pending_schema_events` after every -`ShadowCatalog::sweep_dropped`. Both push into -`XactState.events` as `DrainEntry::Catalog`, keyed on same -`(xid, source_lsn)` triggering record carried. Runtime config changes -use `DrainEntry::Config`; rewrite generations close with -`DrainEntry::ToastBarrier` +Catalog events arrive from descriptor capture at boundary time +([desc_log.md](desc_log.md)), pushed as `DrainEntry::Catalog` keyed +`(drain_xid, valid_from)`; the worker pushes `DrainEntry::Config` at +observe order. Two producers means arrival order is not LSN order — +drain open stable-sorts each xact's events by LSN. Rewrite generations +close with `DrainEntry::ToastBarrier` Tie-break rule (catalog before tuple) matters because when decoder stamps a schema event with triggering heap's `source_lsn` (catalog diff --git a/src/atomic_stats.rs b/src/atomic_stats.rs index 651d1142..283b4b4f 100644 --- a/src/atomic_stats.rs +++ b/src/atomic_stats.rs @@ -1,19 +1,23 @@ //! `atomic_stats!` declares a `#[derive(Debug, Default)]` struct of `AtomicU64` //! counters. No mirror / snapshot type: live struct is the API, so the -//! memory-ordering choice stays visible at every read site. +//! memory-ordering choice stays visible at every read site. A field may name +//! an explicit type (`field: Ty,`) for non-scalar counters; it must be +//! `Debug + Default`. #[macro_export] macro_rules! atomic_stats { ( $(#[$smeta:meta])* $svis:vis struct $name:ident { - $($(#[$fmeta:meta])* $fvis:vis $field:ident,)* + $($(#[$fmeta:meta])* $fvis:vis $field:ident $(: $fty:ty)?,)* } ) => { $(#[$smeta])* #[derive(::core::fmt::Debug, ::core::default::Default)] $svis struct $name { - $($(#[$fmeta])* $fvis $field: ::core::sync::atomic::AtomicU64,)* + $($(#[$fmeta])* $fvis $field: $crate::atomic_stats!(@ty $($fty)?),)* } }; + (@ty) => { ::core::sync::atomic::AtomicU64 }; + (@ty $fty:ty) => { $fty }; } diff --git a/src/backfill/backfill_bootstrap.rs b/src/backfill/backfill_bootstrap.rs index 742f7485..d049ce21 100644 --- a/src/backfill/backfill_bootstrap.rs +++ b/src/backfill/backfill_bootstrap.rs @@ -40,7 +40,6 @@ use crate::backfill::backup_sink::{ }; use crate::backfill::backup_source::{BackupSink, BackupSource, EndInfo, StartInfo}; use crate::decode::decoder_sink::TupleObserver; -use crate::pg::parse_array_one_element; use crate::schema::{RelAttr, RelDescriptor, RelName, ReplIdent}; #[derive(Debug, Clone)] @@ -250,7 +249,10 @@ pub async fn seed_catalog_from_source(client: &Client) -> Result { c.relkind::text, \ c.relpersistence::text, \ c.relreplident::text, \ - c.reltablespace::oid, \ + 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 \ @@ -269,8 +271,9 @@ pub async fn seed_catalog_from_source(client: &Client) -> Result { 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 spc_node: Oid = row.get(7); - let rel_node: Oid = row.get(8); + let toast_oid: Oid = row.get(7); + let spc_node: Oid = row.get(8); + let rel_node: Oid = row.get(9); if rel_node == 0 { // No heap to page-walk (partitioned parent / view / sequence) continue; @@ -285,6 +288,7 @@ pub async fn seed_catalog_from_source(client: &Client) -> Result { let desc = RelDescriptor { rfn, oid, + toast_oid, namespace_oid, rel_name: RelName::new(&namespace_name, &name), kind, @@ -361,47 +365,14 @@ async fn fetch_replident(client: &Client, c: char, rel_oid: Oid) -> Result Result> { - let rows = client - .query( - "SELECT \ - a.attnum::int2, \ - a.attname::text, \ - a.atttypid::oid, \ - a.atttypmod::int4, \ - a.attnotnull::bool, \ - a.attisdropped::bool, \ - t.typname::text, \ - t.typbyval::bool, \ - t.typlen::int2, \ - t.typalign::text, \ - t.typstorage::text, \ - CASE WHEN a.atthasmissing THEN a.attmissingval::text END \ - FROM pg_attribute a \ - JOIN pg_type t ON t.oid = a.atttypid \ - WHERE a.attrelid = $1 AND a.attnum >= 1 \ - ORDER BY a.attnum", - &[&rel_oid], - ) - .await?; - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - let raw_missing: Option = row.get(11); - out.push(RelAttr { - attnum: row.get(0), - name: row.get(1), - type_oid: row.get(2), - typmod: row.get(3), - not_null: row.get(4), - dropped: row.get(5), - type_name: row.get(6), - type_byval: row.get(7), - type_len: row.get(8), - type_align: one_char(row.get::<_, String>(9), "typalign")?, - type_storage: one_char(row.get::<_, String>(10), "typstorage")?, - missing_text: raw_missing.as_deref().and_then(parse_array_one_element), - }); - } - Ok(out) + let rows = client.query(crate::pg::ATTR_SQL, &[&rel_oid]).await?; + rows.iter() + .map(|row| { + crate::pg::RawAttr::from_row(row) + .build() + .map_err(|e| anyhow::anyhow!("bootstrap: {e}")) + }) + .collect() } async fn current_database_oid(client: &Client) -> Result { diff --git a/src/backfill/backfill_staging.rs b/src/backfill/backfill_staging.rs index 4058d93e..878d30d7 100644 --- a/src/backfill/backfill_staging.rs +++ b/src/backfill/backfill_staging.rs @@ -17,7 +17,6 @@ //! differs) from "already copied back" (staging name gone). use std::collections::{HashMap, HashSet}; -use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result, bail}; @@ -115,7 +114,7 @@ pub async fn prepare( rels.push(rel); } Ok(StagingPlan { - mapping: Arc::new(tokio::sync::RwLock::new(staged)), + mapping: crate::mapping::mapping_handle(staged), rels, }) } diff --git a/src/backfill/backfill_types.rs b/src/backfill/backfill_types.rs index f8aff406..1b78ab51 100644 --- a/src/backfill/backfill_types.rs +++ b/src/backfill/backfill_types.rs @@ -5,6 +5,7 @@ use tokio::sync::{Mutex, watch}; use walrus::pg::replication::conn::PgConfig; use crate::budget::MemoryBudget; +use crate::catalog::desc_log::DescriptorLog; use crate::catalog::shadow_catalog::ShadowCatalog; use crate::config::ResolvedConfig; use crate::emit::ch_emitter::{EmitterConfig, EmitterStats}; @@ -23,6 +24,7 @@ pub struct PassContext { pub mapping: MappingHandle, pub stats: Arc, pub catalog: Arc>, + pub log: Arc, pub scratch_dir: PathBuf, pub config_rx: Option>>, pub budget: Option, diff --git a/src/backfill/backup_backfill.rs b/src/backfill/backup_backfill.rs index d5306226..2abb9087 100644 --- a/src/backfill/backup_backfill.rs +++ b/src/backfill/backup_backfill.rs @@ -61,7 +61,7 @@ use crate::backfill::backup_source::{BackupSink, BackupSource}; use crate::backfill::backup_source_direct::DirectSource; use crate::backfill::backup_source_object_store::ObjectStoreSource; use crate::backfill::spool::{DEFERRED_SPOOL_MEM_MAX, DeferredSpool}; -use crate::catalog::shadow_catalog::ShadowCatalog; +use crate::config::ResolvedConfig; use crate::decode::heap_decoder::{CommittedTuple, XLOG_HEAP_OPMASK, XLOG_HEAP_TRUNCATE}; use crate::decode::visibility::{ PgMultiXactAccum, PgXactAccum, PgXactPatch, PgXactView, Visibility, tuple_visibility, @@ -77,7 +77,7 @@ use crate::filter::manifest::Manifest; use crate::filter::pg_class_decoder::{ DecodeOutcome, decode_pg_class_tuple, info_carries_new_tuple_heap, }; -use crate::mapping::MappingHandle; +use crate::mapping::MappingSnapshot; use crate::record::{Record, RecordSink, SegmentSink, SinkError, WAL_SEG_SIZE}; use crate::runtime_config::InitialLoadMode; use crate::schema::RelDescriptor; @@ -324,6 +324,8 @@ async fn walk_and_ship( ctx.stats.clone(), resolver.clone(), DeferredSpool::new(toast_spool_path, DEFERRED_SPOOL_MEM_MAX), + ctx.emitter.soft_delete, + ctx.config_rx.as_ref().map(|rx| rx.borrow().clone()), )); // Success signal before the joins: gate resolves deferred tuples only @@ -692,11 +694,15 @@ impl PrescanSink { let xid = record.parsed.header.xact_id; match info & XLOG_XACT_OPMASK { XLOG_XACT_COMMIT | XLOG_XACT_COMMIT_PREPARED => { - let payload = parse_xact_payload(info, &record.parsed.main_data); + let payload = + parse_xact_payload(info, &record.parsed.main_data, record.page_magic) + .unwrap_or_default(); self.patch.commit(xid, &payload.subxacts); } XLOG_XACT_ABORT | XLOG_XACT_ABORT_PREPARED => { - let payload = parse_xact_payload(info, &record.parsed.main_data); + let payload = + parse_xact_payload(info, &record.parsed.main_data, record.page_magic) + .unwrap_or_default(); self.patch.abort(xid, &payload.subxacts); } _ => {} @@ -841,17 +847,19 @@ async fn replay_gap( } let mut sink = ReplaySink { - decoder: BufferingDecoderSink::new(ctx.catalog.clone(), buffer.clone()), + decoder: BufferingDecoderSink::new(ctx.log.clone(), buffer.clone()), buffer, - catalog: ctx.catalog.clone(), + log: ctx.log.clone(), subxact_tracker: SubxactTracker::new(), resolver, filter_rfns, targets, b_redo, - mapping: ctx.mapping.clone(), + mapping: ctx.mapping.read().await.clone(), stats: ctx.stats.clone(), budget: ctx.budget.clone(), + soft_delete: ctx.emitter.soft_delete, + config: ctx.config_rx.as_ref().map(|rx| rx.borrow().clone()), batch_rows: ctx.emitter.drain_batch_rows, batch_bytes: ctx.emitter.drain_batch_bytes, msg_tx, @@ -880,15 +888,24 @@ async fn replay_gap( struct ReplaySink { decoder: BufferingDecoderSink, buffer: Arc>, - catalog: Arc>, + log: Arc, subxact_tracker: SubxactTracker, resolver: ToastResolver, filter_rfns: HashSet<(Oid, Oid)>, targets: HashMap<(Oid, Oid), (Arc, u64)>, b_redo: u64, - mapping: MappingHandle, + /// Mapping version frozen at replay start: gap replay re-seeds route + /// state from current config, and no config event applies mid-replay + /// (catalog/config drain entries are ignored here), so one snapshot + /// covers the whole replay + mapping: MappingSnapshot, stats: Arc, budget: Option, + /// Boot-only delete-retention policy, frozen into route snapshots + soft_delete: bool, + /// Config snapshot for route freezes: gap replay re-seeds from current + /// config, not history (route history has no WAL position) + config: Option>, /// Drain-slice budget, same knobs as the pipeline reorder batch_rows: usize, batch_bytes: usize, @@ -909,15 +926,16 @@ impl ReplaySink { info: u8, record: &Record<'_>, ) -> std::result::Result<(), SinkError> { - let payload = parse_xact_payload(info, &record.parsed.main_data); + let payload = parse_xact_payload(info, &record.parsed.main_data, record.page_magic) + .unwrap_or_default(); // Deferred resolution for filenodes invisible at record time; // installs decode verdicts + `O - B` barriers ahead of the drain resolve_stash( &self.buffer, - &self.catalog, + &self.log, xid, &payload.subxacts, - record.source_lsn, + record.next_lsn, self.resolver.stats_handle(), ) .await @@ -991,7 +1009,7 @@ impl ReplaySink { debug_assert!(false, "TRUNCATE heap in gap replay"); } WalkStep::Heap(mut heap) => { - let rfn = heap.rfn; + let rfn = heap.decoded.rfn; let Some((rel, s_cap)) = self.targets.get(&(rfn.db_node, rfn.rel_node)) else { continue; }; @@ -1005,25 +1023,28 @@ impl ReplaySink { continue; } let rel = rel.clone(); - let value_permit = detoast_heap( - &mut heap, - spool, - &ref_maps, - &self.catalog, - false, - &self.resolver, - ) - .await - .map_err(SinkError::from)?; - let Some(mapping) = crate::emit::pipeline::lookup_mapping( - &self.mapping, - &rel.rel_name, - &self.stats, - ) - .await - else { + let value_permit = detoast_heap(&mut heap, spool, &ref_maps, &self.resolver) + .await + .map_err(SinkError::from)?; + let Some(mapping) = self.mapping.get(&rel.rel_name) else { + self.stats + .unsupported_relations + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); continue; }; + let mapping = Arc::new(mapping.clone()); + let overrides = self + .config + .as_ref() + .and_then(|rc| rc.columns.get(&rel.rel_name)) + .cloned() + .map(Arc::new) + .unwrap_or_default(); + let route = crate::emit::route::RouteSnapshot::freeze( + mapping, + overrides, + self.soft_delete, + ); let seq = if let Some((seq, rows)) = &mut self.open { *rows += 1; *seq @@ -1038,9 +1059,9 @@ impl ReplaySink { .send(BatcherMsg::Row(RoutedRow { seq, rel, - mapping, + route, committed: CommittedTuple { - decoded: heap, + decoded: heap.decoded, commit_ts, commit_lsn, }, @@ -1082,7 +1103,9 @@ impl RecordSink for ReplaySink { self.on_commit(xid, info, record).await?; } XLOG_XACT_ABORT | XLOG_XACT_ABORT_PREPARED => { - let payload = parse_xact_payload(info, &record.parsed.main_data); + let payload = + parse_xact_payload(info, &record.parsed.main_data, record.page_magic) + .unwrap_or_default(); self.buffer .lock() .await @@ -1154,9 +1177,8 @@ mod tests { ..Default::default() }, source_lsn: 0x5000, - page_magic: 0, route: Route::ToShadow, - catalog_signal: crate::record::CatalogSignal::None, + ..Default::default() } } diff --git a/src/backfill/backup_page_walk.rs b/src/backfill/backup_page_walk.rs index bd3c4fdd..33a1ad1e 100644 --- a/src/backfill/backup_page_walk.rs +++ b/src/backfill/backup_page_walk.rs @@ -678,6 +678,7 @@ pub(crate) fn make_rel() -> RelDescriptor { rel_node: 16400, }, oid: 16400, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", "t"), kind: 'r', diff --git a/src/backfill/copy_backfill.rs b/src/backfill/copy_backfill.rs index 3248fb6e..22202d1f 100644 --- a/src/backfill/copy_backfill.rs +++ b/src/backfill/copy_backfill.rs @@ -499,9 +499,10 @@ pub struct CopyBackfiller { emitter: EmitterConfig, mapping: MappingHandle, stats: Arc, - /// Shadow catalog for backup passes: toast-rel descriptors, gap-replay - /// record decode. + /// Shadow catalog for backup passes: toast-rel descriptors by name catalog: Arc>, + /// Descriptor log for gap-replay record decode + log: Arc, spill_dir: PathBuf, /// Live resolved config for the dedicated backfill tails, so backfilled /// rows encode under the same `config_column` overrides WAL-driven rows @@ -528,6 +529,7 @@ impl CopyBackfiller { mapping: MappingHandle, stats: Arc, catalog: Arc>, + log: Arc, spill_dir: &Path, config_rx: Option>>, budget: Option, @@ -545,6 +547,7 @@ impl CopyBackfiller { mapping, stats, catalog, + log, spill_dir: spill_dir.to_path_buf(), config_rx, budget, @@ -783,6 +786,7 @@ impl CopyBackfiller { mapping: staging.mapping.clone(), stats: self.stats.clone(), catalog: self.catalog.clone(), + log: self.log.clone(), scratch_dir: self.spill_dir.join("backup_backfill"), config_rx: self.config_rx.clone(), budget: self.budget.clone(), @@ -1126,6 +1130,8 @@ impl CopyBackfiller { self.spill_dir.join("copy_deferred.bin"), crate::backfill::spool::DEFERRED_SPOOL_MEM_MAX, ), + self.emitter.soft_delete, + self.config_rx.as_ref().map(|rx| rx.borrow().clone()), )); let (select_list, plan, natts) = column_plan(desc); @@ -1245,6 +1251,7 @@ mod tests { rel_node: 16400, }, oid: 16400, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("app", "orders"), kind: 'r', diff --git a/src/bin/stream.rs b/src/bin/stream.rs index 06e624ed..38777a24 100644 --- a/src/bin/stream.rs +++ b/src/bin/stream.rs @@ -48,6 +48,9 @@ use walshadow::backfill_bootstrap::{ use walshadow::backup_source::BackupSource; use walshadow::backup_source_direct::DirectSource; use walshadow::backup_source_object_store::ObjectStoreSource; +use walshadow::boundary_hold::{ + BoundaryGateConfig, BoundaryHoldSink, BoundaryHoldStats, CatalogBoundaryGate, +}; use walshadow::ch_emitter::{EmitterConfig, EmitterStats}; use walshadow::config::{CliOverrides, ConfigResolver, ResolvedConfig}; use walshadow::decoder_sink::MetricsTupleObserver; @@ -146,7 +149,11 @@ impl RecordSink for DecoderXactPair { /// couple wire pacing to decode. struct DaemonSinks { metrics: MetricsRecordSink, - decoder_xact: QueueingRecordSink, + /// Queueing sink wrapped with the catalog-boundary publication hold: + /// at a catalog-mutating commit the pump parks here until shadow + /// replays through the commit's `next_lsn`, so successor bytes reach + /// neither the shadow wire nor the archive while held. + decoder_xact: BoundaryHoldSink, /// Shared with the `BufferingDecoderSink` on the queueing worker; /// status loop polls without contending on the worker. decoder_stats: Arc, @@ -272,13 +279,21 @@ struct Args { #[arg(long, default_value_t = 64 * 1024 * 1024)] walsender_slow_threshold: usize, /// Seconds the pump waits for shadow's walreceiver to attach before - /// processing records. `0` disables the barrier (shadow driven purely - /// via `restore_command`). `ShadowStreamSink` drops bytes pushed - /// before a connection registers; without the barrier the pump can - /// race past shadow's `START_REPLICATION` LSN and leave the catalog - /// gate timed out against an apply LSN that never advances. + /// processing records. Must be positive; no attachment within it fails + /// startup. Catalog-boundary holds require a live wire: whole archive + /// segments can't stop publication at a mid-segment commit, so + /// archive-only operation (the old `0` escape hatch) is rejected. + /// `ShadowStreamSink` also drops bytes pushed before a connection + /// registers; a pump racing past shadow's `START_REPLICATION` LSN + /// leaves an apply LSN that never advances. #[arg(long, default_value_t = 60)] walsender_connect_timeout: u64, + /// Seconds a catalog-boundary publication hold may wait for shadow to + /// replay through a catalog-mutating commit before failing the daemon. + /// Keep well under source's `wal_sender_timeout` (default 60s): the + /// pump answers no source keepalives while parked. + #[arg(long, default_value_t = 30)] + catalog_hold_timeout: u64, /// Soft cap on in-flight records for the `QueueingRecordSink` feeding /// the decoder / xact-drain worker. Past this watermark the pump /// yields to let the worker drain; a stuck worker still surfaces via @@ -330,14 +345,6 @@ struct Args { /// the command line without editing TOML. Absent defers to TOML. #[arg(long)] drop_table_strategy: Option, - /// Differential decode oracle: probe 1-in-`` rows through shadow - /// PG's `walshadow_decode_disk(oid, bytea)` extension function and - /// assert the local decoder matches. `0` disables. Requires the - /// `walshadow` extension on shadow PG; absent extension surfaces as - /// `oracle fallback=N` and the daemon ships raw on-disk bytes for - /// `PgPending` types. - #[arg(long, default_value_t = 0)] - validate: u32, /// HTTP/Prometheus metrics bind address. Disabled when absent. #[arg(long)] metrics_bind: Option, @@ -589,12 +596,28 @@ fn spawn_sighup_reload( }) } +/// Enforce capability, not flag value: catalog-boundary holds need an +/// active walreceiver, so archive-only operation is not startable. +fn validate_transport_args(args: &Args) -> Result<()> { + anyhow::ensure!( + args.walsender_connect_timeout > 0, + "--walsender-connect-timeout 0 (archive-only shadow) is unsupported: \ + catalog-boundary publication holds require an attached walreceiver", + ); + anyhow::ensure!( + args.catalog_hold_timeout > 0, + "--catalog-hold-timeout must be positive", + ); + Ok(()) +} + /// Process-lifetime entry: bind metrics + control socket + SIGHUP, then stream /// one session. Every reconfigure (socket / SIGHUP) is a live reload — no /// restart. Ctrl-C breaks the pump loop and drains gracefully. async fn run(args: Args) -> Result<()> { use walshadow::control::{Reloader, SharedCtx}; + validate_transport_args(&args)?; let sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()) .inspect_err(|e| { tracing::warn!( @@ -926,21 +949,6 @@ async fn run_session( "shadow connected", ); - // Descriptor-cache invalidation epoch: decoder worker bumps off each - // record's tracker verdict (`with_catalog_signals` below), catalog - // folds the delta into `invalidate` before each relation lookup's - // cache check. Mapping writes + SIGHUP reload bump out-of-band. - let invalidation_epoch = Arc::new(AtomicU64::new(0)); - // DROP-sweep arming, keyed by xid: decoder worker arms at pg_class - // heap_delete records (never ADD COLUMN / CREATE INDEX noise), commit - // sink consumes only at the arming xact's own commit so the replay - // gate makes the drop visible before sweep_dropped probes shadow. - let pending_sweeps = walshadow::catalog_tracker::PendingSweeps::new(); - catalog - .lock() - .await - .set_invalidation_epoch(invalidation_epoch.clone()); - // Create the configured slot before preflight, which requires it to exist. if let Some(slot) = source_slot.as_deref() { feed.ensure_physical_slot(slot) @@ -979,52 +987,25 @@ async fn run_session( tracing::info!(target: "walshadow::preflight", "pre-flight passed"); } - // Seed schema-diff baseline for operator-pinned relations before - // START_REPLICATION so a pinned table's first post-start ALTER diffs - // against boot shape (→ Changed → CH ALTER) rather than cold-prev_known - // Added (apply_added skips pinned dests). cfg.tables.keys() is the - // pinned set; auto-create tables baseline on first-touch CREATE. - if let Some(cfg) = ch_config.as_ref() { - let names: Vec = cfg.tables.keys().cloned().collect(); - let seeded = catalog - .lock() - .await - .seed_baseline(&names) - .await - .context("seed schema-diff baseline for mapped relations")?; - tracing::info!( - target: "walshadow", - seeded, - "seeded schema-diff baseline for mapped relations", - ); - } - // 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, - args.validate, - connect_budget, - ) - .await - { - Ok(o) => { - let ext = o.has_extension(); - tracing::info!( - target: "walshadow::oracle", - validate = args.validate > 0, - sample_rate = args.validate.max(1), - 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 = + 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 + } + }; // START_REPLICATION runs after sinks are built so archive fallback can // advance identical filter and decode paths. @@ -1070,9 +1051,114 @@ async fn run_session( .context("write initial resume manifest after bootstrap")?; } - // Shared schema-events queue (descriptor-fetch + commit-boundary - // `sweep_dropped`); both decoder and drain stage pull from it. - let schema_events = Arc::new(std::sync::Mutex::new(catalog.lock().await.subscribe())); + // Descriptor log: durable shape history captured at catalog boundaries, + // bound to this source + shadow pairing. Sole schema-event source. + let source_major = (feed.server_version_num() / 10000) as u32; + anyhow::ensure!( + (16..=18).contains(&source_major), + "source PG major {source_major} unsupported (commit-record sinval layout audited for 16-18)", + ); + let shadow_db_oid = catalog + .lock() + .await + .current_database_oid() + .await + .context("shadow database oid")?; + stream.filter_mut().set_inval_db(shadow_db_oid); + 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 + // read uncovered intervals. `--ignore-cursor` discards both. + let log_files_present = args.spill_dir.join(walshadow::desc_log::TAIL_FILE).exists() + || args.spill_dir.join(walshadow::desc_log::CKPT_FILE).exists(); + anyhow::ensure!( + manifest_at_boot.is_none() || log_files_present || args.ignore_cursor, + "manifest present but descriptor log missing in {}; \ + re-bootstrap or pass --ignore-cursor", + args.spill_dir.display(), + ); + if args.ignore_cursor { + for f in [ + walshadow::desc_log::CKPT_FILE, + walshadow::desc_log::TAIL_FILE, + ] { + let _ = tokio::fs::remove_file(args.spill_dir.join(f)).await; + } + } + let desc_log = Arc::new( + walshadow::desc_log::DescriptorLog::open( + &args.spill_dir, + walshadow::desc_log::DescLogIdentity { + pg_major: source_major, + system_id: ident.sysid.clone(), + timeline: ident.timeline, + db_oid: shadow_db_oid, + wal_seg_size: WAL_SEG_SIZE as u32, + }, + ) + .await + .context("open descriptor log")?, + ); + if let Some(lsn) = start_lsn_override { + anyhow::ensure!( + lsn >= desc_log.floor_at_write(), + "--start-lsn {} below descriptor log floor {}; no shape history \ + survives there — --ignore-cursor or re-bootstrap", + format_pg_lsn(lsn), + format_pg_lsn(desc_log.floor_at_write()), + ); + let head = desc_log.head(); + anyhow::ensure!( + head == 0 || lsn <= head, + "--start-lsn {} beyond descriptor log head {}; boundaries in \ + between were never captured — --ignore-cursor re-baselines", + format_pg_lsn(lsn), + format_pg_lsn(head), + ); + } + if desc_log.is_empty() { + // Baseline snapshot: every eligible rel as of shadow's position, + // valid from the aligned start so the prefix re-read decodes + // (newest-shape reader of older tuples — the safe bias direction). + // Boundaries at or below covered_through are baked in and skip. + let (replay_lsn, descs) = catalog + .lock() + .await + .fetch_all_descriptors() + .await + .context("descriptor log boot seed")?; + let covered_through = raw_start.max(replay_lsn); + let entries = descs + .into_iter() + .map(|d| { + Arc::new(walshadow::desc_log::LogEntry { + valid_from: aligned, + oid: d.oid, + rfn: d.rfn, + value: walshadow::desc_log::LogValue::Present(Arc::new(d)), + }) + }) + .collect(); + desc_log + .seed( + walshadow::desc_log::BatchRecord { + captured_at: covered_through, + commit_lsn: 0, + observations: Vec::new(), + ambiguities: Vec::new(), + entries, + }, + covered_through, + ) + .await + .context("seed descriptor log")?; + tracing::info!( + target: "walshadow::desc_log", + covered_through = format_pg_lsn(covered_through).to_string(), + "descriptor log seeded", + ); + } + // Txn-span registry, shared by pump + decoder; `Some` only with OTLP on. let span_registry = if args.otlp_endpoint.is_some() || std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok() { @@ -1080,13 +1166,7 @@ async fn run_session( } else { None }; - let mut decoder = BufferingDecoderSink::new(catalog.clone(), xact_buffer.clone()) - .with_schema_events(schema_events.clone()) - // Bump / arm at worker position off each record's tracker verdict: - // the queueing sink below decouples the decoder from the pump, so - // a pump-position bump would be consumable before pre-DDL records - // finish decoding - .with_catalog_signals(invalidation_epoch.clone(), Some(pending_sweeps.clone())); + let mut decoder = BufferingDecoderSink::new(desc_log.clone(), xact_buffer.clone()); if let Some(schema) = ch_config .as_ref() .and_then(|c| c.runtime_config_schema.as_deref()) @@ -1131,9 +1211,9 @@ async fn run_session( let pcfg = if let Some(mut emitter_cfg) = ch_config { let addr = format!("{}:{}", emitter_cfg.host, emitter_cfg.port); - // Live routing map shared by DDL applicator + decode pool. The + // Live routing map shared by DDL applicator + route planning. The // refresher below rewrites it on every republished snapshot. - let mapping: MappingHandle = Arc::new(tokio::sync::RwLock::new(emitter_cfg.tables.clone())); + let mapping = walshadow::mapping::mapping_handle(emitter_cfg.tables.clone()); // Resolver merges CLI over TOML and publishes ResolvedConfig on // the watch substrate; SIGHUP re-reads TOML and republishes. The // mapping refresher + DDL applicator subscribe. @@ -1149,7 +1229,6 @@ async fn run_session( args.ch_config.clone(), cli_source_base(args), mapping.clone(), - invalidation_epoch.clone(), ); reloader.set_resolver(Some(resolver.clone())).await; spawn_mapping_refresher(config_rx.clone(), mapping.clone()); @@ -1196,7 +1275,6 @@ async fn run_session( ) .await .context("init DDL applicator")? - .with_invalidation_epoch(invalidation_epoch.clone()) .with_resolver(resolver.clone()); let stats = Arc::new(EmitterStats::default()); emitter_stats_handle = Some(stats.clone()); @@ -1226,6 +1304,7 @@ async fn run_session( mapping.clone(), stats.clone(), catalog.clone(), + desc_log.clone(), &args.spill_dir, Some(config_rx.clone()), Some(pipeline_budget.clone()), @@ -1335,8 +1414,7 @@ async fn run_session( tail: TailKind::ClickHouse, buffer: xact_buffer.clone(), subxact_tracker: Arc::new(Mutex::new(SubxactTracker::new())), - schema_events: Some(schema_events.clone()), - pending_sweeps: Some(pending_sweeps.clone()), + log: desc_log.clone(), stats: stats.clone(), span_registry: span_registry.clone(), config_resolver: config_resolver.clone(), @@ -1368,8 +1446,7 @@ async fn run_session( tail: TailKind::Null, buffer: xact_buffer.clone(), subxact_tracker: Arc::new(Mutex::new(SubxactTracker::new())), - schema_events: Some(schema_events.clone()), - pending_sweeps: Some(pending_sweeps.clone()), + log: desc_log.clone(), stats: Arc::new(EmitterStats::default()), span_registry: span_registry.clone(), config_resolver: None, @@ -1387,6 +1464,10 @@ async fn run_session( .flush_due_retires() .await .context("boot flush of due toast-mirror retires")?; + reorder_sink + .apply_boot_events(desc_log.active_present_at(raw_start), raw_start) + .await + .context("boot Added pass over descriptor log")?; let decoder_xact = QueueingRecordSink::spawn( DecoderXactPair { decoder, @@ -1396,6 +1477,22 @@ async fn run_session( args.decoder_queue_capacity, span_registry.clone(), ); + let boundary_gate = CatalogBoundaryGate::new( + shadow_state.clone(), + BoundaryGateConfig { + hold_timeout: Duration::from_secs(args.catalog_hold_timeout), + ..BoundaryGateConfig::default() + }, + ); + let boundary_hold_stats = boundary_gate.stats.clone(); + let capture = walshadow::catalog_capture::CatalogCapture::new( + desc_log.clone(), + catalog.clone(), + xact_buffer.clone(), + smgr_markers, + ); + let capture_stats = capture.stats_handle(); + let decoder_xact = BoundaryHoldSink::new(decoder_xact, boundary_gate).with_capture(capture); let mut record_sink = DaemonSinks { metrics: MetricsRecordSink::default(), decoder_xact, @@ -1417,6 +1514,12 @@ async fn run_session( let mut segment_sink = DirSegmentSink::with_durability(args.out_dir.clone(), WAL_SEG_SIZE, fsync_tx) .context("open out-dir")?; + // Descriptor-log GC off the pump task: the pump publishes each persisted + // floor, the task compacts. Coalesces by construction — a watch holds + // only the latest floor. + let gc_fatal = walshadow::pipeline::Fatal::new(); + let (gc_floor_tx, gc_floor_rx) = tokio::sync::watch::channel(0u64); + let gc_task = spawn_desc_log_gc(desc_log.clone(), gc_floor_rx, gc_fatal.clone()); let mut chunk_buf = Vec::with_capacity(64 * 1024); // Metrics endpoint + control socket + SIGHUP are process-lifetime (bound in @@ -1454,36 +1557,31 @@ async fn run_session( // racing past `START_REPLICATION`'s LSN before walreceiver arrives // leaves an unrecoverable gap: post-conn frames carry LSNs past // walreceiver's expected continuity, shadow's apply stalls, the catalog - // gate times out (pgbench_acceptance / kill_restart failure mode). Cap - // the wait so operators without a streaming shadow still boot via the - // restore_command archive path. - if args.walsender_connect_timeout > 0 { + // gate times out (pgbench_acceptance / kill_restart failure mode). No + // attachment fails startup: catalog-boundary holds require a live wire, + // and archive-only operation can't stop publication at a mid-segment + // commit (restore_command must never observe unreleased bytes). + { let timeout = Duration::from_secs(args.walsender_connect_timeout); let start = Instant::now(); - let mut attached = false; loop { if shadow_state.lock().await.aggregate().active_connections > 0 { - attached = true; break; } - if start.elapsed() >= timeout { - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - if attached { - tracing::info!( - target: "walshadow", - wait = ?start.elapsed(), - "walsender connected — starting pump", - ); - } else { - tracing::warn!( - target: "walshadow", - timeout_secs = args.walsender_connect_timeout, - "no walsender connection within boot barrier — proceeding (shadow on restore_command path)", + anyhow::ensure!( + start.elapsed() < timeout, + "no walreceiver attached to walsender {walsender_addr} within \ + {}s; catalog-boundary holds require a live wire — point \ + shadow's primary_conninfo here or raise --walsender-connect-timeout", + args.walsender_connect_timeout, ); + tokio::time::sleep(Duration::from_millis(100)).await; } + tracing::info!( + target: "walshadow", + wait = ?start.elapsed(), + "walsender connected — starting pump", + ); } let source_recovery = SourceRecovery { @@ -1575,6 +1673,10 @@ async fn run_session( // Publish only after persist: pruners cut against what a // crash-now restart actually resumes from. resume_floor.store(cur.floor.0, Ordering::Release); + // Descriptor log prunes against the same floor, off this task: a + // compaction rewrites the whole ckpt inline and would stall WAL + // consumption past the source's wal_sender_timeout + gc_floor_tx.send_replace(cur.floor.0); } // flush caps physical slot's restart_lsn. // Manifest writes are cadence-gated above while keepalive replies inside @@ -1648,6 +1750,9 @@ async fn run_session( if let Some(msg) = fsync_fatal.message() { anyhow::bail!("segment fsync failed: {msg}"); } + if let Some(msg) = gc_fatal.message() { + anyhow::bail!("{msg}"); + } let now_dispatched = stream.dispatched_lsn(); let advanced = now_dispatched != prev_dispatched; let (xact_stats, drain_resident, xact_line) = { @@ -1659,6 +1764,8 @@ async fn run_session( chunks: b.drain_chunk_resident_bytes(), rows: b.drain_row_resident_bytes(), spool: b.toast_spool_bytes(), + raw_pending_rows: b.raw_pending_rows(), + raw_pending_bytes: b.raw_pending_bytes(), }; (stats, resident, line) }; @@ -1701,6 +1808,9 @@ async fn run_session( active_connections: shadow_agg.active_connections as u64, dropped_total: shadow_agg.dropped_total, }, + &boundary_hold_stats, + &capture_stats, + &desc_log, metrics_resolver.as_deref(), metrics_backfiller.as_deref(), ) @@ -1798,6 +1908,13 @@ async fn run_session( if let Some(msg) = fsync_fatal.message() { anyhow::bail!("segment fsync failed: {msg}"); } + // Close the floor channel and join: nothing else may own desc_log.ckpt + // after the session returns + drop(gc_floor_tx); + gc_task.await.ok(); + if let Some(msg) = gc_fatal.message() { + anyhow::bail!("{msg}"); + } // Drain queueing worker so enqueued-but-undispatched records run // through decoder + xact_drain before exit; surfaces worker-parked errors. let DaemonSinks { decoder_xact, .. } = record_sink; @@ -2030,7 +2147,7 @@ fn spawn_mapping_refresher( // Boot value already seeded into `mapping`; react to republishes. while config_rx.changed().await.is_ok() { let tables = config_rx.borrow_and_update().tables.clone(); - *mapping.write().await = tables; + *mapping.write().await = Arc::new(tables); tracing::info!( target: "walshadow::config", "routing map refreshed from resolved config", @@ -2099,6 +2216,28 @@ fn spawn_segment_fsync( }) } +/// Compact the descriptor log against each published resume floor, off the +/// pump task. A compaction rewrites the whole ckpt inline; on the pump that +/// stalls WAL consumption while `wal_sender_timeout` runs with no keepalive +/// answered. Boundary capture still shares the log's writer mutex, so a +/// boundary landing mid-compaction blocks its hold — this removes the stall +/// for boundary-free stretches, which is the common case. +fn spawn_desc_log_gc( + desc_log: Arc, + mut floor_rx: tokio::sync::watch::Receiver, + fatal: walshadow::pipeline::Fatal, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + while floor_rx.changed().await.is_ok() { + let floor = *floor_rx.borrow_and_update(); + if let Err(e) = desc_log.maybe_gc(floor).await { + fatal.set(format!("descriptor log gc at {floor:#X}: {e}")); + return; + } + } + }) +} + /// Every [`DEFAULT_TRIM_INTERVAL`], read shadow replay LSN and last /// restartpoint REDO LSN, then trim below /// `min(replay_lsn - retention_bytes, redo)` @@ -2228,6 +2367,8 @@ struct DrainResident { chunks: u64, rows: u64, spool: u64, + raw_pending_rows: u64, + raw_pending_bytes: u64, } #[allow(clippy::too_many_arguments)] @@ -2249,12 +2390,17 @@ async fn populate_metrics( oracle_stats: Option<&walshadow::oracle::OracleStats>, uptime_secs: u64, shadow_view: ShadowMetricsView, + boundary_hold: &BoundaryHoldStats, + capture: &walshadow::catalog_capture::CaptureStats, + desc_log: &walshadow::desc_log::DescriptorLog, config_resolver: Option<&ConfigResolver>, backfiller: Option<&walshadow::copy_backfill::CopyBackfiller>, ) { use std::collections::BTreeMap; use walshadow::record::rmgr_label; let (proc_cpu, proc_rss) = read_process_stats(); + let desc_log_gauges = desc_log.gauges(); + let log_stats = desc_log.stats_handle(); let mut by_rm = BTreeMap::new(); for ((rm, route), n) in &rec_metrics.by_rm_route { let key = ( @@ -2318,15 +2464,77 @@ async fn populate_metrics( .map(|s| s.toast_rewrite_barriers.load(Ordering::Relaxed)) .unwrap_or(0), toast_stash_buffered_total: decoder_stats.toast_stash_buffered.load(Ordering::Relaxed), + raw_stash_deferred_total: decoder_stats.raw_stash_deferred.load(Ordering::Relaxed), toast_stash_decoded_total: emitter_stats .map(|s| s.toast_stash_decoded.load(Ordering::Relaxed)) .unwrap_or(0), toast_stash_discarded_total: emitter_stats .map(|s| s.toast_stash_discarded.load(Ordering::Relaxed)) .unwrap_or(0), - toast_stash_skipped_total: emitter_stats - .map(|s| s.toast_stash_skipped.load(Ordering::Relaxed)) + stash_foreign_db_skipped_total: emitter_stats + .map(|s| s.stash_foreign_db_skipped.load(Ordering::Relaxed)) .unwrap_or(0), + xact_plan_rows: emitter_stats + .map(|s| s.plan_rows.load(Ordering::Relaxed)) + .unwrap_or(0), + xact_plan_bytes_by_storage: emitter_stats + .map(|s| { + [ + s.plan_bytes_mem.load(Ordering::Relaxed), + s.plan_bytes_file.load(Ordering::Relaxed), + ] + }) + .unwrap_or_default(), + xact_plan_failures_by_reason: emitter_stats + .map(|s| { + [ + s.plan_failures_spool.load(Ordering::Relaxed), + s.plan_failures_fail_closed_image_only + .load(Ordering::Relaxed), + s.plan_failures_fail_closed_malformed + .load(Ordering::Relaxed), + s.plan_failures_fail_closed_unsupported_op + .load(Ordering::Relaxed), + s.plan_failures_stash_ambiguous.load(Ordering::Relaxed), + s.plan_failures_incomplete_toast.load(Ordering::Relaxed), + s.plan_failures_missing_stash_resolution + .load(Ordering::Relaxed), + s.plan_failures_detoast.load(Ordering::Relaxed), + s.plan_failures_partial_update.load(Ordering::Relaxed), + s.plan_failures_view.load(Ordering::Relaxed), + s.plan_failures_drain.load(Ordering::Relaxed), + ] + }) + .unwrap_or_default(), + route_snapshots_by_result: emitter_stats + .map(|s| { + [ + s.route_snapshots_mapped.load(Ordering::Relaxed), + s.route_snapshots_unmapped.load(Ordering::Relaxed), + ] + }) + .unwrap_or_default(), + raw_stash_records_by_kind_op: [ + decoder_stats.raw_stash_dirty_ops.load(), + decoder_stats.raw_stash_marker_ops.load(), + ], + raw_stash_bytes_by_storage: [ + xact_stats.raw_stash_bytes_mem, + xact_stats.raw_stash_bytes_spill, + ], + raw_decode_records_by_kind_op: emitter_stats + .map(|s| { + [ + s.raw_decode_toast_ops.load(), + s.raw_decode_ordinary_ops.load(), + ] + }) + .unwrap_or_default(), + raw_decode_rows_by_op: emitter_stats + .map(|s| s.raw_decode_rows_ops.load()) + .unwrap_or_default(), + raw_pending_rows: drain_resident.raw_pending_rows, + raw_pending_bytes: drain_resident.raw_pending_bytes, emitter_rows_total: emitter_stats .map(|s| s.rows_emitted.load(Ordering::Relaxed)) .unwrap_or(0), @@ -2367,12 +2575,6 @@ async fn populate_metrics( oracle_fallback_raw_total: oracle_stats .map(|s| s.fallback_raw.load(Ordering::Relaxed)) .unwrap_or(0), - oracle_validate_sampled_total: oracle_stats - .map(|s| s.probes.load(Ordering::Relaxed)) - .unwrap_or(0), - oracle_validate_mismatches_total: oracle_stats - .map(|s| s.mismatches.load(Ordering::Relaxed)) - .unwrap_or(0), oracle_errors_total: oracle_stats .map(|s| s.errors.load(Ordering::Relaxed)) .unwrap_or(0), @@ -2381,6 +2583,30 @@ async fn populate_metrics( shadow_apply_lag_seconds: shadow_view.apply_lag_seconds, shadow_stream_active_connections: shadow_view.active_connections, shadow_stream_dropped_connections_total: shadow_view.dropped_total, + catalog_boundary_holds_total: boundary_hold.holds.load(Ordering::Relaxed), + catalog_boundary_hold_failures_total: boundary_hold.failures.load(Ordering::Relaxed), + catalog_boundary_hold_seconds_total: boundary_hold.hold_seconds_total(), + desc_capture_sql_total: capture.sql_captures.load(Ordering::Relaxed), + desc_capture_log_replay_total: capture.log_replays.load(Ordering::Relaxed), + desc_capture_skipped_covered_total: capture.skipped_covered.load(Ordering::Relaxed), + desc_capture_all_total: capture.capture_all_runs.load(Ordering::Relaxed), + desc_capture_rels_total: capture.rels_captured.load(Ordering::Relaxed), + desc_capture_seconds_total: capture.capture_nanos.load(Ordering::Relaxed) as f64 / 1e9, + desc_events_added_total: capture.events_added.load(Ordering::Relaxed), + 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), + desc_log_entries: desc_log_gauges.0, + desc_log_tail_bytes: desc_log_gauges.1, + desc_log_batches: desc_log_gauges.2, + desc_log_gc_total: log_stats.gc_runs.load(Ordering::Relaxed), + desc_log_gc_dropped_entries_total: log_stats.gc_dropped_entries.load(Ordering::Relaxed), + desc_lookups_present_total: log_stats.lookups_present.load(Ordering::Relaxed), + desc_lookups_dropped_total: log_stats.lookups_dropped.load(Ordering::Relaxed), + desc_lookups_retired_total: log_stats.lookups_retired.load(Ordering::Relaxed), + desc_lookups_ambiguous_total: log_stats.lookups_ambiguous.load(Ordering::Relaxed), + desc_lookups_not_covered_total: log_stats.lookups_not_covered.load(Ordering::Relaxed), + desc_lookups_foreign_db_total: log_stats.lookups_foreign_db.load(Ordering::Relaxed), config_pending_decl_rels: config_resolver.map(|r| r.pending_decl_count()).unwrap_or(0), config_replicate_opt_in_total: config_resolver.map(|r| r.opt_in_total()).unwrap_or(0), config_replicate_opt_out_total: config_resolver.map(|r| r.opt_out_total()).unwrap_or(0), @@ -2699,7 +2925,7 @@ async fn run_bootstrap( .await .context("bootstrap: spawn insert tail")?; // Static [table.*] mapping (no SIGHUP, no shadow PG during bootstrap). - let mapping: MappingHandle = Arc::new(tokio::sync::RwLock::new(emitter_cfg.tables.clone())); + let mapping = walshadow::mapping::mapping_handle(emitter_cfg.tables.clone()); tracing::info!( target: "walshadow::bootstrap", addr = %addr, @@ -2721,6 +2947,9 @@ async fn run_bootstrap( deferred_path, walshadow::spool::DEFERRED_SPOOL_MEM_MAX, ), + emitter_cfg.soft_delete, + // Static mapping above: no overlay during greenfield bootstrap + None, )); let (drain_res, pump_res) = tokio::join!(drain, pump); let drain_outcome = drain_res @@ -3397,6 +3626,16 @@ mod tests { ); } + #[test] + fn transport_args_reject_archive_only_and_zero_hold_timeout() { + assert!(validate_transport_args(&args_from(&[])).is_ok()); + assert!( + validate_transport_args(&args_from(&["--walsender-connect-timeout", "0"])).is_err(), + "archive-only escape hatch must fail startup", + ); + assert!(validate_transport_args(&args_from(&["--catalog-hold-timeout", "0"])).is_err()); + } + #[test] fn walsender_conninfo_skipped_on_kernel_picked_port() { assert!(walsender_primary_conninfo("127.0.0.1:0".parse().unwrap()).is_none()); diff --git a/src/catalog/compat.rs b/src/catalog/compat.rs new file mode 100644 index 00000000..f267ae4d --- /dev/null +++ b/src/catalog/compat.rs @@ -0,0 +1,326 @@ +//! Physical compatibility predicate: can the final committed descriptor +//! decode tuples written earlier in the same dirty interval? +//! +//! Compared fields are what tuple walking + value interpretation read: +//! attnum sequence, dropped slots, attlen/attalign/attbyval, type oid, +//! missing-value semantics. Rename, replica identity, and not-null are +//! metadata for decode purposes +//! +//! Rejects split by physical consequence ([`Incompat`]). A `Physical` +//! reject means no descriptor reads both formats, so capture fences the +//! interval; a `Benign` one means the declared shape drifted while every +//! byte reads the same, so bias-early history stays sound. The split is +//! what keeps the fence off the shapes PG actually produces in place — +//! layout-moving ALTERs rewrite the relation into a fresh filenode, and a +//! rotation never reaches this predicate +//! +//! Dropped slots keep physical walk fields: PG `RemoveAttributeById` +//! (`src/backend/catalog/heap.c`) preserves attlen/attalign/attbyval and +//! zeroes atttypid, clears attmissingval + +use crate::schema::{RelAttr, RelDescriptor}; + +/// Why `new` isn't a proven reader of tuples formatted under `old` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Incompat { + /// Physical read is identical; only the declared shape drifts + Benign(&'static str), + /// Walk fields or datum identity differ; no descriptor reads both + Physical(&'static str), +} + +impl Incompat { + /// Failing check's name, for logs and ambiguity diagnostics + pub fn why(&self) -> &'static str { + match self { + Self::Benign(why) | Self::Physical(why) => why, + } + } + + pub fn is_physical(&self) -> bool { + matches!(self, Self::Physical(_)) + } +} + +/// `Ok(())` when `new` provably decodes tuples formatted under `old`; +/// `Err` names the first failing check, with `Physical` winning over +/// `Benign` wherever both apply +pub fn compatible_reader(old: &RelDescriptor, new: &RelDescriptor) -> Result<(), Incompat> { + if old.oid != new.oid { + return Err(Incompat::Physical("oid mismatch")); + } + if old.rfn != new.rfn { + return Err(Incompat::Physical("filenode rotated")); + } + if old.kind != new.kind { + return Err(Incompat::Physical("relkind change")); + } + if old.persistence != new.persistence { + return Err(Incompat::Physical("persistence change")); + } + // Old rows' external pointers resolve against the toast relation they + // were written under; 0 -> oid is toast creation, old rows predate it + if old.toast_oid != 0 && old.toast_oid != new.toast_oid { + return Err(Incompat::Physical("toast relation change")); + } + if new.attributes.len() < old.attributes.len() { + return Err(Incompat::Physical("attribute truncation")); + } + let mut benign: Option<&'static str> = None; + for (o, n) in old.attributes.iter().zip(&new.attributes) { + if let Err(e) = slot_compatible(o, n) { + let Incompat::Benign(why) = e else { + return Err(e); + }; + benign = benign.or(Some(why)); + } + } + // Appended columns: old tuples read the stored missing value, or NULL. + // NOT NULL without a missing value implies the rewrite path, which this + // predicate must not bless for in-place history + for n in &new.attributes[old.attributes.len()..] { + if !n.dropped && n.not_null && n.missing_text.is_none() { + return Err(Incompat::Physical( + "appended not-null column without missing value", + )); + } + } + benign.map_or(Ok(()), |why| Err(Incompat::Benign(why))) +} + +fn slot_compatible(o: &RelAttr, n: &RelAttr) -> Result<(), Incompat> { + if o.attnum != n.attnum { + return Err(Incompat::Physical("attnum sequence change")); + } + // Walk fields are read regardless of dropped state + if o.type_len != n.type_len || o.type_align != n.type_align || o.type_byval != n.type_byval { + return Err(Incompat::Physical("physical walk fields change")); + } + if o.dropped && !n.dropped { + // PG re-adds at a fresh attnum, never resurrects a dropped slot + return Err(Incompat::Physical("dropped slot resurrected")); + } + if n.dropped { + // Present -> dropped inside the interval: value discarded either + // way; atttypid/attmissingval zeroed on drop, walk fields checked + return Ok(()); + } + // Same width can still reinterpret: int4 vs date both walk 4 bytes + if o.type_oid != n.type_oid { + return Err(Incompat::Physical("type change")); + } + // Tuples shorter than attnum read the missing value; a different one + // reinterprets history + if o.missing_text != n.missing_text { + return Err(Incompat::Physical("missing value change")); + } + // Below: no reader consults these. The walk reads attlen/attalign/ + // attbyval only (PG `src/backend/access/common/heaptuple.c`) and + // numeric/varlena datums carry their own scale and length, so a widened + // typmod reinterprets nothing + if o.typmod != n.typmod { + return Err(Incompat::Benign("typmod change")); + } + // attstorage drives writer-side toasting choices (PG + // `src/backend/access/table/toast_helper.c`); the reader detects + // external/compressed per datum from the varlena header (PG + // `src/include/varatt.h`) + if o.type_storage != n.type_storage { + return Err(Incompat::Benign("storage change")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema::{RelName, ReplIdent}; + use walrus::pg::walparser::RelFileNode; + + fn attr(attnum: i16, type_oid: u32, type_len: i16) -> RelAttr { + RelAttr { + attnum, + name: format!("c{attnum}"), + type_oid, + typmod: -1, + not_null: false, + dropped: false, + type_name: "t".into(), + type_byval: type_len > 0, + type_len, + type_align: 'i', + type_storage: if type_len > 0 { 'p' } else { 'x' }, + missing_text: None, + } + } + + fn dropped_slot(attnum: i16, type_len: i16) -> RelAttr { + RelAttr { + type_oid: 0, + dropped: true, + name: format!("........pg.dropped.{attnum}........"), + type_name: String::new(), + missing_text: None, + not_null: false, + ..attr(attnum, 0, type_len) + } + } + + fn rel(attributes: Vec) -> RelDescriptor { + RelDescriptor { + rfn: RelFileNode { + spc_node: 1663, + db_node: 5, + rel_node: 7000, + }, + oid: 42, + toast_oid: 0, + namespace_oid: 2200, + rel_name: RelName::new("public", "t"), + kind: 'r', + persistence: 'p', + replident: ReplIdent::Default { pk_attnums: None }, + attributes, + } + } + + #[test] + fn metadata_changes_allowed() { + let old = rel(vec![attr(1, 23, 4)]); + let mut new = rel(vec![attr(1, 23, 4)]); + new.rel_name = RelName::new("renamed_ns", "renamed"); + new.namespace_oid = 9999; + new.replident = ReplIdent::Full { pk_attnums: None }; + new.attributes[0].name = "renamed_col".into(); + new.attributes[0].not_null = true; + assert_eq!(compatible_reader(&old, &new), Ok(())); + } + + #[test] + fn append_only_columns() { + let old = rel(vec![attr(1, 23, 4)]); + // Nullable append, no missing value: old rows read NULL + let new = rel(vec![attr(1, 23, 4), attr(2, 20, 8)]); + assert_eq!(compatible_reader(&old, &new), Ok(())); + // NOT NULL append with stored missing value + let mut with_missing = attr(2, 20, 8); + with_missing.not_null = true; + with_missing.missing_text = Some("7".into()); + let new = rel(vec![attr(1, 23, 4), with_missing]); + assert_eq!(compatible_reader(&old, &new), Ok(())); + // NOT NULL append without missing value = rewrite territory + let mut bad = attr(2, 20, 8); + bad.not_null = true; + let new = rel(vec![attr(1, 23, 4), bad]); + assert!(compatible_reader(&old, &new).is_err()); + // Added-then-dropped inside the interval appends a dropped slot + let new = rel(vec![attr(1, 23, 4), dropped_slot(2, 8)]); + assert_eq!(compatible_reader(&old, &new), Ok(())); + } + + #[test] + fn physical_changes_rejected() { + let old = rel(vec![attr(1, 23, 4)]); + let type_change = rel(vec![attr(1, 20, 8)]); + assert!( + compatible_reader(&old, &type_change) + .unwrap_err() + .is_physical() + ); + // Same width, different type: walk matches, values reinterpret + let mut same_width = rel(vec![attr(1, 23, 4)]); + same_width.attributes[0].type_oid = 1082; // date + assert_eq!( + compatible_reader(&old, &same_width), + Err(Incompat::Physical("type change")) + ); + let mut missing = rel(vec![attr(1, 23, 4)]); + missing.attributes[0].missing_text = Some("1".into()); + assert_eq!( + compatible_reader(&old, &missing), + Err(Incompat::Physical("missing value change")) + ); + let truncated = rel(vec![]); + assert!( + compatible_reader(&old, &truncated) + .unwrap_err() + .is_physical() + ); + let reorder = rel(vec![attr(2, 23, 4)]); + assert!(compatible_reader(&old, &reorder).unwrap_err().is_physical()); + } + + #[test] + fn declared_shape_drift_is_benign() { + let old = rel(vec![attr(1, 1043, -1)]); + // varchar(10) -> varchar(20): varlena carries its own length + let mut typmod = rel(vec![attr(1, 1043, -1)]); + typmod.attributes[0].typmod = 24; + assert_eq!( + compatible_reader(&old, &typmod), + Err(Incompat::Benign("typmod change")) + ); + let mut storage = rel(vec![attr(1, 1043, -1)]); + storage.attributes[0].type_storage = 'e'; + assert_eq!( + compatible_reader(&old, &storage), + Err(Incompat::Benign("storage change")) + ); + // Physical wins wherever both apply, whatever the slot order + let mut both = rel(vec![attr(1, 1043, -1), attr(2, 23, 4)]); + both.attributes[0].typmod = 24; + both.attributes[1].missing_text = Some("1".into()); + let old_two = rel(vec![attr(1, 1043, -1), attr(2, 23, 4)]); + assert_eq!( + compatible_reader(&old_two, &both), + Err(Incompat::Physical("missing value change")) + ); + } + + #[test] + fn dropped_slot_transitions() { + let old = rel(vec![attr(1, 23, 4), attr(2, 20, 8)]); + // Drop preserves walk fields: compatible + let new = rel(vec![attr(1, 23, 4), dropped_slot(2, 8)]); + assert_eq!(compatible_reader(&old, &new), Ok(())); + // Dropped slot with altered walk fields cannot parse old tuples + let new = rel(vec![attr(1, 23, 4), dropped_slot(2, 4)]); + assert!(compatible_reader(&old, &new).is_err()); + // Resurrection: PG never reuses a dropped attnum + let was_dropped = rel(vec![attr(1, 23, 4), dropped_slot(2, 8)]); + let resurrected = rel(vec![attr(1, 23, 4), attr(2, 20, 8)]); + assert!(compatible_reader(&was_dropped, &resurrected).is_err()); + // Dropped in both stays compatible + assert_eq!( + compatible_reader(&was_dropped, &was_dropped.clone()), + Ok(()) + ); + } + + #[test] + fn relation_level_changes() { + let old = rel(vec![attr(1, 23, 4)]); + let mut kind = rel(vec![attr(1, 23, 4)]); + kind.kind = 'm'; + assert!(compatible_reader(&old, &kind).is_err()); + let mut persistence = rel(vec![attr(1, 23, 4)]); + persistence.persistence = 'u'; + assert!(compatible_reader(&old, &persistence).is_err()); + // Toast creation: old rows predate any external pointer + let mut toast_added = rel(vec![attr(1, 23, 4)]); + toast_added.toast_oid = 8800; + assert_eq!(compatible_reader(&old, &toast_added), Ok(())); + // Toast replacement invalidates old external pointers + let mut old_toast = rel(vec![attr(1, 23, 4)]); + old_toast.toast_oid = 8800; + let mut new_toast = rel(vec![attr(1, 23, 4)]); + new_toast.toast_oid = 8801; + assert!(compatible_reader(&old_toast, &new_toast).is_err()); + let mut rotated = rel(vec![attr(1, 23, 4)]); + rotated.rfn.rel_node = 7001; + assert!(compatible_reader(&old, &rotated).is_err()); + let mut other_oid = rel(vec![attr(1, 23, 4)]); + other_oid.oid = 43; + assert!(compatible_reader(&old, &other_oid).is_err()); + } +} diff --git a/src/catalog/desc_log.rs b/src/catalog/desc_log.rs new file mode 100644 index 00000000..47be4df8 --- /dev/null +++ b/src/catalog/desc_log.rs @@ -0,0 +1,2495 @@ +//! Durable descriptor log: append-only relation-shape history, captured at +//! catalog-commit boundaries, read interval-scoped by decode. +//! +//! Decode asks `descriptor(rfn, L)` / `descriptor(oid, L)` — a point-in-time +//! question. The log stores per-key version chains (`valid_from` ascending); +//! lookup binary-searches the last entry at or below `L`. Capture appends one +//! batch per catalog boundary keyed `captured_at` = the commit's EndRecPtr, +//! fsyncs, then publishes to the in-memory index — records reaching decode +//! always have coverage. +//! +//! ## On-disk layout +//! +//! Two files under the spill dir: `desc_log.ckpt` (compacted snapshot, +//! replaced atomically at GC) + `desc_log.tail` (framed appends since). +//! Both open with the same header: +//! +//! ```text +//! [2 "WL"] [u16 LE version] [u32 pg_major] [str system_id] [u32 timeline] +//! [u32 db_oid] [u32 wal_seg_size] +//! ``` +//! +//! then frames `[u32 LE len] [u32 LE crc32c(body)] [body]`; `body[0]` tags +//! `0` meta (ckpt only: `covered_through`, `floor_at_write`) or `1` batch. +//! Identity mismatch is fatal (foreign spill dir), version mismatch is fatal +//! (no cross-version compatibility by design). +//! +//! Tail repair: a frame extending past EOF or failing CRC as the final frame +//! is a torn append — truncate + `sync_data`. CRC failure with valid data +//! after is interior corruption — fail closed. GC writes the ckpt first, +//! then truncates the tail; a crash between leaves duplicate batches, which +//! load dedupes by `captured_at` (byte-equal skip, divergent fail-closed). +//! +//! ## Interval semantics +//! +//! `Present` opens an interval at `valid_from` (bias-early: the descriptor is +//! a backward-compatible reader of older tuples, never the reverse). +//! `Dropped` tombstones relation + rfn. `Retired` closes an rfn's interval on +//! filenode rotation without relation-level effects: rewrite/TRUNCATE hold +//! AccessExclusiveLock so no decode query lands past it — the entry exists so +//! GC can drop rotated-away chains and buggy callers fail closed, not open. +//! +//! ## Ambiguity intervals +//! +//! A boundary whose shape change cannot be proven safe for one descriptor +//! records an [`Ambiguity`]: a `[from_lsn, through_lsn)` interval scoped to +//! an rfn, oid, or whole database. Lookup consults ambiguity intervals +//! before the descriptor chain — a covered LSN answers +//! [`LookupResult::Ambiguous`] even when a chain entry exists, so callers +//! fail closed instead of decoding under an unproven layout. The final +//! post-commit `Present` entry stays usable past `through_lsn` +//! +//! Each interval files under exactly one key, and each lookup consults its +//! own key plus the database scope, so producers publish one interval per +//! identity key they can name — capture emits an `Rfn` and an `Oid` interval +//! for the same verdict. Deferred records resolve at the commit's +//! `next_lsn`, past their own interval's end, so the drain fences per record +//! against [`DescriptorLog::ambiguities_intersecting`] rather than relying +//! on the point lookup +//! +//! Identity keys the full physical `RelFileNode`: PG guarantees +//! relfilenumber uniqueness only per database of one tablespace +//! (`GetNewRelFileNumber`, PG `src/backend/catalog/catalog.c`), so +//! `(db_node, rel_node)` alone can alias two live relations after OID +//! wraparound. Capture resolves the `pg_class.reltablespace` 0 sentinel to +//! the database's `dattablespace`, so stored rfns compare directly against +//! WAL locators' physical spcOid. See `plans/future/TABLESPACES.md` §0. +//! +//! Known scope debt: an interval fences its whole span, so DML that +//! provably postdates the layout change inside it still fails closed. +//! Per-record fidelity needs an intra-xact descriptor timeline — +//! `plans/future/descriptor_timeline.md`. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::SeekFrom; +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicU64; +use std::sync::{Arc, RwLock}; + +use thiserror::Error; +use tokio::fs::{File, OpenOptions}; +use tokio::io::{AsyncSeekExt, AsyncWriteExt}; +use tokio_postgres::types::Oid; +use walrus::pg::walparser::RelFileNode; + +use crate::schema::{RelAttr, RelDescriptor, RelName, ReplIdent}; + +pub const CKPT_FILE: &str = "desc_log.ckpt"; +pub const TAIL_FILE: &str = "desc_log.tail"; + +const MAGIC: &[u8; 2] = b"WL"; +/// 3: ambiguity intervals fence per record at the drain. A v2 log's +/// intervals were published for read-identical drift too and span past the +/// layout change, so replaying them under the fence would fail closed on +/// rows v2 decoded fine — reject the log instead +const VERSION: u16 = 3; +/// Reject frames past this before allocating: no realistic batch (thousands +/// of descriptors) approaches it, garbage lengths do +const MAX_FRAME: u32 = 256 * 1024 * 1024; +const TAG_META: u8 = 0; +const TAG_BATCH: u8 = 1; + +/// GC when this many entries are droppable below the floor +const GC_DEAD_ENTRIES: usize = 512; +/// or when the tail outgrows this +const GC_TAIL_BYTES: u64 = 8 * 1024 * 1024; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum DescLogError { + #[error("descriptor log io: {0}")] + Io(#[from] std::io::Error), + #[error("unsupported descriptor log version {0} (this build expects {VERSION})")] + Version(u16), + #[error("foreign descriptor log: {field} mismatch (log has {log}, this source is {ours})")] + ForeignLog { + field: &'static str, + log: String, + ours: String, + }, + #[error("descriptor log corrupt at {file}:{offset}: {detail}")] + Corrupt { + file: &'static str, + offset: u64, + detail: String, + }, +} + +/// Binds log files to one source + shadow pairing; any mismatch at open is +/// fatal, mirroring `ManifestError::ForeignSource`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DescLogIdentity { + pub pg_major: u32, + pub system_id: String, + pub timeline: u32, + pub db_oid: Oid, + pub wal_seg_size: u32, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum LogValue { + Present(Arc), + /// Relation dropped: tombstones oid + rfn chains + Dropped, + /// Filenode rotated away (rewrite/TRUNCATE/SET TABLESPACE): closes the + /// rfn chain only, relation lives on under its new rfn + Retired, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LogEntry { + pub valid_from: u64, + pub oid: Oid, + /// Full physical rfn (tablespace sentinel resolved at capture). For + /// `Present` this is the descriptor's rfn; for tombstones the chain + /// being closed + pub rfn: RelFileNode, + pub value: LogValue, +} + +/// Interval `[from_lsn, through_lsn)` where no single descriptor provably +/// decodes rows in `scope` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ambiguity { + pub scope: AmbiguityScope, + pub from_lsn: u64, + pub through_lsn: u64, + pub reason: AmbiguityReason, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AmbiguityScope { + Rfn(RelFileNode), + Oid(Oid), + /// Conservative fallback when affected relations cannot be enumerated + Database(Oid), +} + +/// Discriminants are the wire tags; only produced variants exist, so an +/// operator never sees a label that cannot fire +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum AmbiguityReason { + /// Descriptor changed in place and the dirty interval's exact mutation + /// position is unknown (only the first catalog touch is tracked) + UnknownMutationPosition = 1, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct BatchRecord { + /// Boundary commit EndRecPtr (or seed LSN); the replay-from-log key + pub captured_at: u64, + /// Commit record start LSN; 0 for seed batches + pub commit_lsn: u64, + /// Evidence the boundary verdict derives from, kept so replay + /// reproduces the verdict instead of reinferring it from current + /// catalog. Deterministic order (capture sorts) + pub observations: Vec, + /// Intervals this boundary could not prove decodable under one + /// descriptor + pub ambiguities: Vec>, + /// Empty = stub: boundary produced no shape change, recorded so boot + /// replay distinguishes "captured, nothing changed" from "never captured" + pub entries: Vec>, +} + +impl BatchRecord { + /// Digest over the deterministic encoding; divergence diagnostics + pub fn digest(&self) -> u32 { + crc32c::crc32c(&encode_batch(self)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RelationObservation { + pub oid: Option, + pub rfn: Option, + pub first_touch_lsn: u64, + /// Main-fork smgr create marker: new generation lower bound + pub smgr_create_lsn: Option, + pub kind: ObservationKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObservationKind { + /// Dirty-tracker pg_class decode or commit relcache inval named the oid + AffectedOid, + /// Smgr create marker registered for the filenode + SmgrCreate, + /// Enumeration incomplete, capture fell back to full catalog scan + FullScan, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum LookupResult { + Present(Arc), + Dropped, + Retired, + /// LSN falls inside a recorded ambiguity interval: no descriptor is + /// proven safe, callers fail closed + Ambiguous(Arc), + NotCovered, + /// Foreign `db_node`: preserves the `ForeignDatabase` row-skip control + /// flow — never a stash or a fatal + ForeignDb, +} + +/// One key's version chain slot; `captured_at` denormalized for +/// [`DescriptorLog::predecessor_before`] +#[derive(Debug, Clone)] +struct Slot { + valid_from: u64, + captured_at: u64, + entry: Arc, +} + +#[derive(Debug, Default)] +struct Index { + by_rfn: HashMap>, + by_oid: HashMap>, + amb_rfn: HashMap>>, + amb_oid: HashMap>>, + amb_db: HashMap>>, + batches: BTreeMap>, + covered_through: u64, + floor_at_write: u64, + entries_total: usize, +} + +impl Index { + /// Files each ambiguity under exactly the key its scope names. Producers + /// publish one interval per identity key so the rfn-keyed and oid-keyed + /// lookups see the same fence without either consulting the other's map + fn insert_batch(&mut self, batch: Arc) { + for amb in &batch.ambiguities { + let list = match amb.scope { + AmbiguityScope::Rfn(rfn) => self.amb_rfn.entry(rfn).or_default(), + AmbiguityScope::Oid(oid) => self.amb_oid.entry(oid).or_default(), + AmbiguityScope::Database(db) => self.amb_db.entry(db).or_default(), + }; + let key = (amb.from_lsn, amb.through_lsn); + let pos = list.partition_point(|a| (a.from_lsn, a.through_lsn) <= key); + list.insert(pos, amb.clone()); + } + for entry in &batch.entries { + let slot = Slot { + valid_from: entry.valid_from, + captured_at: batch.captured_at, + entry: entry.clone(), + }; + let rfn_chain = self.by_rfn.entry(entry.rfn).or_default(); + // Retired closes an rfn chain only; the relation lives on under + // its new rfn, whose Present entry shares this oid + batch — a + // Retired slot in by_oid would shadow it + if !matches!(entry.value, LogValue::Retired) { + let oid_chain = self.by_oid.entry(entry.oid).or_default(); + insert_sorted(oid_chain, slot.clone()); + } + insert_sorted(rfn_chain, slot); + self.entries_total += 1; + } + self.batches.insert(batch.captured_at, batch); + } +} + +/// Chains stay `(valid_from, captured_at)`-ascending. Appends hit the tail +/// (DDL on one rel serializes under AccessExclusiveLock, so per-key order is +/// total); boot replay re-inserts nothing (batch dedupe upstream). +fn insert_sorted(chain: &mut Vec, slot: Slot) { + let key = (slot.valid_from, slot.captured_at); + let pos = chain.partition_point(|s| (s.valid_from, s.captured_at) <= key); + chain.insert(pos, slot); +} + +#[derive(Debug)] +struct Writer { + tail: File, + tail_len: u64, + header_len: u64, + dir: PathBuf, +} + +crate::atomic_stats! { + pub struct DescLogStats { + pub lookups_present, + pub lookups_dropped, + pub lookups_retired, + pub lookups_ambiguous, + pub lookups_not_covered, + pub lookups_foreign_db, + pub batches_appended, + pub gc_runs, + pub gc_dropped_entries, + } +} + +#[derive(Debug)] +pub struct DescriptorLog { + identity: DescLogIdentity, + index: RwLock, + writer: tokio::sync::Mutex, + /// `tail_len - header_len` mirror, readable without the writer lock + tail_bytes: AtomicU64, + stats: Arc, +} + +impl DescriptorLog { + /// Load `desc_log.ckpt` + `desc_log.tail` under `dir`, repairing a torn + /// tail. Creates an empty tail (header only) when absent. Never creates + /// the ckpt — [`Self::seed`] and GC own it. + pub async fn open(dir: &Path, identity: DescLogIdentity) -> Result { + let mut index = Index::default(); + let ckpt_path = dir.join(CKPT_FILE); + if let Ok(bytes) = tokio::fs::read(&ckpt_path).await { + // write_atomic guarantees complete-or-absent: any parse failure + // here is corruption, never a torn write + load_frames(&bytes, CKPT_FILE, &identity, &mut index, false)?; + } + + let tail_path = dir.join(TAIL_FILE); + let header = encode_header(&identity); + let (tail, tail_len) = match tokio::fs::read(&tail_path).await { + Ok(bytes) => { + let good_len = load_frames(&bytes, TAIL_FILE, &identity, &mut index, true)?; + let mut f = OpenOptions::new().write(true).open(&tail_path).await?; + if good_len < bytes.len() as u64 { + f.set_len(good_len).await?; + f.sync_data().await?; + } + f.seek(SeekFrom::Start(good_len)).await?; + (f, good_len) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let mut f = OpenOptions::new() + .write(true) + .create_new(true) + .open(&tail_path) + .await?; + f.write_all(&header).await?; + f.sync_data().await?; + crate::fs::fsync_dir(dir).await?; + (f, header.len() as u64) + } + Err(e) => return Err(e.into()), + }; + + Ok(Self { + identity, + index: RwLock::new(index), + writer: tokio::sync::Mutex::new(Writer { + tail, + tail_len, + header_len: header.len() as u64, + dir: dir.to_path_buf(), + }), + tail_bytes: AtomicU64::new(tail_len - header.len() as u64), + stats: Arc::new(DescLogStats::default()), + }) + } + + fn publish_tail_bytes(&self, w: &Writer) { + self.tail_bytes.store( + w.tail_len - w.header_len, + std::sync::atomic::Ordering::Relaxed, + ); + } + + pub fn stats_handle(&self) -> Arc { + self.stats.clone() + } + + pub fn covered_through(&self) -> u64 { + self.index.read().unwrap().covered_through + } + + /// Floor recorded by the last GC ckpt write; `--start-lsn` below it has + /// no descriptor history + pub fn floor_at_write(&self) -> u64 { + self.index.read().unwrap().floor_at_write + } + + /// Highest `captured_at`, 0 when empty + pub fn head(&self) -> u64 { + self.index + .read() + .unwrap() + .batches + .keys() + .next_back() + .copied() + .unwrap_or(0) + } + + pub fn is_empty(&self) -> bool { + let idx = self.index.read().unwrap(); + idx.batches.is_empty() && idx.covered_through == 0 + } + + pub fn descriptor_at(&self, rfn: RelFileNode, lsn: u64) -> LookupResult { + match self.descriptor_at_spanned(rfn, lsn) { + Ok((rel, _)) => LookupResult::Present(rel), + Err(other) => other, + } + } + + /// `Present` descriptor plus its entry's `valid_from` under one index + /// read (two reads could interleave with a bias-early capture append + /// and pair a stale descriptor with a fresh span). Non-Present outcomes + /// return the plain lookup for the caller's arms + pub fn descriptor_at_spanned( + &self, + rfn: RelFileNode, + lsn: u64, + ) -> std::result::Result<(Arc, u64), LookupResult> { + use std::sync::atomic::Ordering::Relaxed; + if rfn.db_node != 0 && rfn.db_node != self.identity.db_oid { + self.stats.lookups_foreign_db.fetch_add(1, Relaxed); + return Err(LookupResult::ForeignDb); + } + let idx = self.index.read().unwrap(); + // Ambiguity precedes the chain: a chain entry inside an ambiguous + // interval is not proven safe for rows there. The db scope keys on + // the locator's own db_node, so a shared relation (db_node 0) skips + // this database's Database-scoped intervals — shared catalogs carry + // no descriptors, so no such interval can name their rows + let result = ambiguity_covering(idx.amb_rfn.get(&rfn), lsn) + .or_else(|| ambiguity_covering(idx.amb_db.get(&rfn.db_node), lsn)) + .map_or_else( + || lookup_spanned(idx.by_rfn.get(&rfn), lsn), + |a| Err(LookupResult::Ambiguous(a)), + ); + self.count_spanned(&result); + result + } + + pub fn descriptor_by_oid_at(&self, oid: Oid, lsn: u64) -> LookupResult { + match self.descriptor_by_oid_at_spanned(oid, lsn) { + Ok((rel, _)) => LookupResult::Present(rel), + Err(other) => other, + } + } + + /// Oid-keyed twin of [`Self::descriptor_at_spanned`] + pub fn descriptor_by_oid_at_spanned( + &self, + oid: Oid, + lsn: u64, + ) -> std::result::Result<(Arc, u64), LookupResult> { + let idx = self.index.read().unwrap(); + let result = ambiguity_covering(idx.amb_oid.get(&oid), lsn) + .or_else(|| ambiguity_covering(idx.amb_db.get(&self.identity.db_oid), lsn)) + .map_or_else( + || lookup_spanned(idx.by_oid.get(&oid), lsn), + |a| Err(LookupResult::Ambiguous(a)), + ); + self.count_spanned(&result); + result + } + + fn count_spanned(&self, result: &std::result::Result<(Arc, u64), LookupResult>) { + use std::sync::atomic::Ordering::Relaxed; + match result { + Ok(_) => { + self.stats.lookups_present.fetch_add(1, Relaxed); + } + Err(other) => self.count(other), + } + } + + fn count(&self, result: &LookupResult) { + use std::sync::atomic::Ordering::Relaxed; + match result { + LookupResult::Present(_) => self.stats.lookups_present.fetch_add(1, Relaxed), + LookupResult::Dropped => self.stats.lookups_dropped.fetch_add(1, Relaxed), + LookupResult::Retired => self.stats.lookups_retired.fetch_add(1, Relaxed), + LookupResult::Ambiguous(_) => self.stats.lookups_ambiguous.fetch_add(1, Relaxed), + LookupResult::NotCovered => self.stats.lookups_not_covered.fetch_add(1, Relaxed), + LookupResult::ForeignDb => self.stats.lookups_foreign_db.fetch_add(1, Relaxed), + }; + } + + /// Intervals overlapping `[from, through)` for a relation, under every + /// key that can name it. The drain's per-record fence: a stashed record + /// resolves at the commit's `next_lsn`, past its own interval's + /// `through_lsn`, so the point lookups above cannot see the fence that + /// covers it. + /// + /// Overlap, not containment — an interval opening below `from` still + /// covers records inside the range, and the caller narrows per record + pub fn ambiguities_intersecting( + &self, + rfn: RelFileNode, + oid: Option, + from: u64, + through: u64, + ) -> Vec> { + if rfn.db_node != 0 && rfn.db_node != self.identity.db_oid { + return Vec::new(); + } + let idx = self.index.read().unwrap(); + let mut out: Vec> = Vec::new(); + let lists = [ + idx.amb_rfn.get(&rfn), + oid.and_then(|o| idx.amb_oid.get(&o)), + idx.amb_db.get(&rfn.db_node), + ]; + for amb in lists.into_iter().flatten().flatten() { + // Siblings of one verdict are distinct allocations; the same Arc + // reaches here only when two keys index it + if amb.from_lsn < through + && from < amb.through_lsn + && !out.iter().any(|k| Arc::ptr_eq(k, amb)) + { + out.push(amb.clone()); + } + } + out.sort_unstable_by_key(|a| (a.from_lsn, a.through_lsn)); + out + } + + /// Newest `Present` at or below `lsn` on the rfn chain, skipping + /// tombstones. Truncate apply resolves the relation an rfn named even + /// after its own commit retired it: rotation records `Retired` at the + /// new generation's bias-early valid_from, which precedes the truncate + /// record itself. Bypasses the ambiguity fence: the caller is applying a + /// TRUNCATE, which needs the relation's identity, not its tuple layout. + pub fn present_before(&self, rfn: RelFileNode, lsn: u64) -> Option> { + let idx = self.index.read().unwrap(); + let chain = idx.by_rfn.get(&rfn)?; + let upto = chain.partition_point(|s| s.valid_from <= lsn); + chain[..upto] + .iter() + .rev() + .find_map(|s| match &s.entry.value { + LogValue::Present(d) => Some(d.clone()), + _ => None, + }) + } + + /// Replay-from-log hit test for a boundary at `captured_at` + pub fn batch_at(&self, captured_at: u64) -> Option> { + self.index + .read() + .unwrap() + .batches + .get(&captured_at) + .cloned() + } + + /// The oid's entry preceding the batch at `captured_at` in history + /// order — replay derives events against this, never the loaded head + /// (boot loads the full log before the WAL re-read). Bypasses the + /// ambiguity fence by construction: capture compares descriptors here, + /// it does not read tuples + pub fn predecessor_before(&self, oid: Oid, captured_at: u64) -> Option> { + let idx = self.index.read().unwrap(); + let chain = idx.by_oid.get(&oid)?; + let pos = chain.partition_point(|s| s.captured_at < captured_at); + pos.checked_sub(1).map(|i| chain[i].entry.clone()) + } + + /// Ambiguity intervals recorded for an rfn, `(from_lsn, through_lsn)` + /// ascending — introspection for diagnostics and tests + pub fn rfn_ambiguities(&self, rfn: RelFileNode) -> Vec> { + let idx = self.index.read().unwrap(); + idx.amb_rfn.get(&rfn).cloned().unwrap_or_default() + } + + /// Oid-keyed twin of [`Self::rfn_ambiguities`]: the sibling interval a + /// physical in-place verdict files for the same event + pub fn oid_ambiguities(&self, oid: Oid) -> Vec> { + let idx = self.index.read().unwrap(); + idx.amb_oid.get(&oid).cloned().unwrap_or_default() + } + + /// Oids whose newest entry is `Present` — capture-all diffs its SQL + /// enumeration against this to tombstone vanished relations + pub fn present_oids(&self) -> Vec { + let idx = self.index.read().unwrap(); + idx.by_oid + .iter() + .filter(|(_, chain)| { + matches!( + chain.last().map(|s| &s.entry.value), + Some(LogValue::Present(_)) + ) + }) + .map(|(oid, _)| *oid) + .collect() + } + + /// Descriptors `Present` at `lsn` — boot Added enumeration. Bypasses the + /// ambiguity fence: an ambiguous interval says nothing about whether the + /// relation exists, and the consumer only emits idempotent + /// `CREATE TABLE IF NOT EXISTS`, never reads a tuple + pub fn active_present_at(&self, lsn: u64) -> Vec> { + let idx = self.index.read().unwrap(); + idx.by_oid + .values() + .filter_map(|chain| Some(lookup_spanned(Some(chain), lsn).ok()?.0)) + .collect() + } + + /// One-time baseline on an empty log: writes the ckpt (meta + + /// seed batch) so `covered_through` is durable before any tail append. + /// Boundaries at or below `covered_through` are baked into the seed — + /// no capture, no event replay. + pub async fn seed(&self, batch: BatchRecord, covered_through: u64) -> Result<()> { + let w = self.writer.lock().await; + { + let idx = self.index.read().unwrap(); + if !idx.batches.is_empty() || idx.covered_through != 0 { + return Err(DescLogError::Corrupt { + file: CKPT_FILE, + offset: 0, + detail: "seed on a non-empty log".into(), + }); + } + } + let batch = Arc::new(batch); + let mut bytes = encode_header(&self.identity); + push_frame(&mut bytes, &encode_meta(covered_through, 0)); + push_frame(&mut bytes, &encode_batch(&batch)); + crate::fs::write_atomic(&w.dir, CKPT_FILE, &bytes).await?; + let mut idx = self.index.write().unwrap(); + idx.covered_through = covered_through; + idx.insert_batch(batch); + Ok(()) + } + + /// Append one boundary batch: frame → `sync_data` → index publish. + /// Idempotent per `captured_at` (byte-identical replays no-op); a + /// divergent batch at an existing key fails closed. + pub async fn append_batch(&self, batch: BatchRecord) -> Result<()> { + let mut w = self.writer.lock().await; + { + let idx = self.index.read().unwrap(); + if let Some(existing) = idx.batches.get(&batch.captured_at) { + if **existing == batch { + return Ok(()); + } + return Err(DescLogError::Corrupt { + file: TAIL_FILE, + offset: w.tail_len, + detail: format!( + "append at captured_at {:#x} diverges from stored batch \ + (digest {:#010x} vs stored {:#010x})", + batch.captured_at, + batch.digest(), + existing.digest(), + ), + }); + } + } + let batch = Arc::new(batch); + let mut frame = Vec::new(); + push_frame(&mut frame, &encode_batch(&batch)); + w.tail.write_all(&frame).await?; + w.tail.sync_data().await?; + w.tail_len += frame.len() as u64; + self.publish_tail_bytes(&w); + self.stats + .batches_appended + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.index.write().unwrap().insert_batch(batch); + Ok(()) + } + + /// Compact when the tail outgrew `GC_TAIL_BYTES` or at least + /// `GC_DEAD_ENTRIES` entries are droppable below `floor`. Retention + /// keeps, per key, the state active at the floor: the last entry at or + /// below it when `Present`; a `Dropped`/`Retired` there drops the whole + /// at-or-below history (nothing above can reference it — records + /// predate the drop, and the floor never exceeds the re-read start). + /// Batches above the floor survive whole (stubs included) for boot + /// replay; below it they exist only as carriers of retained entries. + pub async fn maybe_gc(&self, floor: u64) -> Result { + let w = self.writer.lock().await; + let tail_bytes = w.tail_len - w.header_len; + let (retained, dropped_entries) = { + let idx = self.index.read().unwrap(); + // Count before rebuilding: this runs at cursor-write cadence and + // usually declines, while compute_retained re-sorts every chain + let dropped = droppable_entries(&idx, floor); + if dropped < GC_DEAD_ENTRIES && tail_bytes < GC_TAIL_BYTES { + return Ok(false); + } + (compute_retained(&idx, floor), dropped) + }; + self.gc_locked(w, retained, dropped_entries as u64, floor) + .await?; + Ok(true) + } + + #[cfg(test)] + pub async fn force_gc(&self, floor: u64) -> Result<()> { + let w = self.writer.lock().await; + let (retained, dropped) = { + let idx = self.index.read().unwrap(); + let retained = compute_retained(&idx, floor); + let dropped = idx.entries_total - retained.entries_total; + (retained, dropped) + }; + self.gc_locked(w, retained, dropped as u64, floor).await + } + + async fn gc_locked( + &self, + mut w: tokio::sync::MutexGuard<'_, Writer>, + mut retained: Index, + dropped_entries: u64, + floor: u64, + ) -> Result<()> { + use std::sync::atomic::Ordering::Relaxed; + retained.floor_at_write = floor; + let mut bytes = encode_header(&self.identity); + push_frame(&mut bytes, &encode_meta(retained.covered_through, floor)); + for batch in retained.batches.values() { + push_frame(&mut bytes, &encode_batch(batch)); + } + crate::fs::write_atomic(&w.dir, CKPT_FILE, &bytes).await?; + // Crash before this truncate leaves ckpt+tail overlapping; load + // dedupes by captured_at + let header_len = w.header_len; + w.tail.set_len(header_len).await?; + w.tail.seek(SeekFrom::Start(header_len)).await?; + w.tail.sync_data().await?; + w.tail_len = w.header_len; + self.publish_tail_bytes(&w); + *self.index.write().unwrap() = retained; + self.stats.gc_runs.fetch_add(1, Relaxed); + self.stats + .gc_dropped_entries + .fetch_add(dropped_entries, Relaxed); + Ok(()) + } + + /// (entries, bytes-in-tail, batches) gauges. Tail bytes come from the + /// mirror, not the writer: GC runs off the pump task and would otherwise + /// dip the gauge to 0 for the length of a compaction + pub fn gauges(&self) -> (u64, u64, u64) { + let idx = self.index.read().unwrap(); + let entries = idx.entries_total as u64; + let batches = idx.batches.len() as u64; + drop(idx); + ( + entries, + self.tail_bytes.load(std::sync::atomic::Ordering::Relaxed), + batches, + ) + } +} + +/// Newest interval covering `lsn` under `[from_lsn, through_lsn)`. Lists are +/// `(from_lsn, through_lsn)`-sorted, so `partition_point` bounds the search +/// to intervals that opened at or below `lsn` and the reverse walk answers +/// from the newest — GC only drops intervals below the floor, so the list +/// tracks floor lag rather than workload and the prefix skip matters +fn ambiguity_covering(list: Option<&Vec>>, lsn: u64) -> Option> { + let list = list?; + let opened = list.partition_point(|a| a.from_lsn <= lsn); + list[..opened] + .iter() + .rev() + .find(|a| lsn < a.through_lsn) + .cloned() +} + +fn lookup_spanned( + chain: Option<&Vec>, + lsn: u64, +) -> std::result::Result<(Arc, u64), LookupResult> { + let Some(chain) = chain else { + return Err(LookupResult::NotCovered); + }; + let pos = chain.partition_point(|s| s.valid_from <= lsn); + let Some(slot) = pos.checked_sub(1).map(|i| &chain[i]) else { + return Err(LookupResult::NotCovered); + }; + match &slot.entry.value { + LogValue::Present(d) => Ok((d.clone(), slot.valid_from)), + LogValue::Dropped => Err(LookupResult::Dropped), + LogValue::Retired => Err(LookupResult::Retired), + } +} + +/// Per key, the entry whose state is active at `floor`. Entry identity by +/// allocation: each `Arc` is inserted once and shared between its +/// batch and both chains +fn keep_at_floor(idx: &Index, floor: u64) -> HashSet<*const LogEntry> { + let mut keep: HashSet<*const LogEntry> = HashSet::new(); + for chain in idx.by_rfn.values().chain(idx.by_oid.values()) { + let below = &chain[..chain.partition_point(|s| s.valid_from <= floor)]; + if let Some(last) = below.last() + && matches!(last.entry.value, LogValue::Present(_)) + { + keep.insert(Arc::as_ptr(&last.entry)); + } + } + keep +} + +/// Entries [`compute_retained`] would drop at `floor`, without building the +/// retained index — the GC threshold test +fn droppable_entries(idx: &Index, floor: u64) -> usize { + let keep = keep_at_floor(idx, floor); + idx.batches + .range(..=floor) + .flat_map(|(_, batch)| &batch.entries) + .filter(|e| e.valid_from <= floor && !keep.contains(&Arc::as_ptr(e))) + .count() +} + +fn compute_retained(idx: &Index, floor: u64) -> Index { + let keep = keep_at_floor(idx, floor); + let mut out = Index { + covered_through: idx.covered_through, + floor_at_write: idx.floor_at_write, + ..Default::default() + }; + for (&captured_at, batch) in &idx.batches { + if captured_at > floor { + out.insert_batch(batch.clone()); + continue; + } + let entries: Vec> = batch + .entries + .iter() + .filter(|e| e.valid_from > floor || keep.contains(&Arc::as_ptr(e))) + .cloned() + .collect(); + // Half-open interval: through_lsn == floor covers nothing at or + // above the floor + let ambiguities: Vec> = batch + .ambiguities + .iter() + .filter(|a| a.through_lsn > floor) + .cloned() + .collect(); + if !entries.is_empty() || !ambiguities.is_empty() { + out.insert_batch(Arc::new(BatchRecord { + captured_at, + commit_lsn: batch.commit_lsn, + observations: batch.observations.clone(), + ambiguities, + entries, + })); + } + } + out +} + +// ── encoding ──────────────────────────────────────────────────────── + +fn push_u8(out: &mut Vec, v: u8) { + out.push(v); +} +fn push_u16(out: &mut Vec, v: u16) { + out.extend_from_slice(&v.to_le_bytes()); +} +fn push_i16(out: &mut Vec, v: i16) { + out.extend_from_slice(&v.to_le_bytes()); +} +fn push_u32(out: &mut Vec, v: u32) { + out.extend_from_slice(&v.to_le_bytes()); +} +fn push_i32(out: &mut Vec, v: i32) { + out.extend_from_slice(&v.to_le_bytes()); +} +fn push_u64(out: &mut Vec, v: u64) { + out.extend_from_slice(&v.to_le_bytes()); +} +fn push_str(out: &mut Vec, s: &str) { + push_u32(out, s.len() as u32); + out.extend_from_slice(s.as_bytes()); +} +fn push_opt_str(out: &mut Vec, s: Option<&str>) { + match s { + None => push_u8(out, 0), + Some(s) => { + push_u8(out, 1); + push_str(out, s); + } + } +} + +fn encode_header(id: &DescLogIdentity) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(MAGIC); + push_u16(&mut out, VERSION); + push_u32(&mut out, id.pg_major); + push_str(&mut out, &id.system_id); + push_u32(&mut out, id.timeline); + push_u32(&mut out, id.db_oid); + push_u32(&mut out, id.wal_seg_size); + out +} + +fn encode_meta(covered_through: u64, floor_at_write: u64) -> Vec { + let mut out = vec![TAG_META]; + push_u64(&mut out, covered_through); + push_u64(&mut out, floor_at_write); + out +} + +fn encode_batch(batch: &BatchRecord) -> Vec { + let mut out = vec![TAG_BATCH]; + push_u64(&mut out, batch.captured_at); + push_u64(&mut out, batch.commit_lsn); + push_u32(&mut out, batch.entries.len() as u32); + for entry in &batch.entries { + push_u64(&mut out, entry.valid_from); + push_u32(&mut out, entry.oid); + push_u32(&mut out, entry.rfn.spc_node); + push_u32(&mut out, entry.rfn.db_node); + push_u32(&mut out, entry.rfn.rel_node); + match &entry.value { + LogValue::Present(d) => { + push_u8(&mut out, 0); + encode_descriptor(&mut out, d); + } + LogValue::Dropped => push_u8(&mut out, 1), + LogValue::Retired => push_u8(&mut out, 2), + } + } + push_u32(&mut out, batch.ambiguities.len() as u32); + for amb in &batch.ambiguities { + match amb.scope { + AmbiguityScope::Rfn(rfn) => { + push_u8(&mut out, 0); + push_u32(&mut out, rfn.spc_node); + push_u32(&mut out, rfn.db_node); + push_u32(&mut out, rfn.rel_node); + } + AmbiguityScope::Oid(oid) => { + push_u8(&mut out, 1); + push_u32(&mut out, oid); + } + AmbiguityScope::Database(db) => { + push_u8(&mut out, 2); + push_u32(&mut out, db); + } + } + push_u64(&mut out, amb.from_lsn); + push_u64(&mut out, amb.through_lsn); + push_u8(&mut out, amb.reason as u8); + } + push_u32(&mut out, batch.observations.len() as u32); + for obs in &batch.observations { + match obs.oid { + None => push_u8(&mut out, 0), + Some(oid) => { + push_u8(&mut out, 1); + push_u32(&mut out, oid); + } + } + match obs.rfn { + None => push_u8(&mut out, 0), + Some(rfn) => { + push_u8(&mut out, 1); + push_u32(&mut out, rfn.spc_node); + push_u32(&mut out, rfn.db_node); + push_u32(&mut out, rfn.rel_node); + } + } + push_u64(&mut out, obs.first_touch_lsn); + match obs.smgr_create_lsn { + None => push_u8(&mut out, 0), + Some(lsn) => { + push_u8(&mut out, 1); + push_u64(&mut out, lsn); + } + } + push_u8(&mut out, obs.kind as u8); + } + out +} + +fn encode_descriptor(out: &mut Vec, d: &RelDescriptor) { + push_u32(out, d.rfn.spc_node); + push_u32(out, d.rfn.db_node); + push_u32(out, d.rfn.rel_node); + push_u32(out, d.oid); + push_u32(out, d.toast_oid); + push_u32(out, d.namespace_oid); + push_str(out, &d.rel_name.namespace); + push_str(out, &d.rel_name.name); + push_u8(out, d.kind as u8); + push_u8(out, d.persistence as u8); + match &d.replident { + ReplIdent::Default { pk_attnums } => { + push_u8(out, 0); + encode_opt_attnums(out, pk_attnums.as_deref()); + } + ReplIdent::Nothing => push_u8(out, 1), + ReplIdent::Full { pk_attnums } => { + push_u8(out, 2); + encode_opt_attnums(out, pk_attnums.as_deref()); + } + ReplIdent::UsingIndex { + index_oid, + key_attnums, + } => { + push_u8(out, 3); + push_u32(out, *index_oid); + encode_attnums(out, key_attnums); + } + } + push_u32(out, d.attributes.len() as u32); + for a in &d.attributes { + push_i16(out, a.attnum); + push_str(out, &a.name); + push_u32(out, a.type_oid); + push_i32(out, a.typmod); + push_u8(out, a.not_null as u8); + push_u8(out, a.dropped as u8); + push_str(out, &a.type_name); + push_u8(out, a.type_byval as u8); + push_i16(out, a.type_len); + push_u8(out, a.type_align as u8); + push_u8(out, a.type_storage as u8); + push_opt_str(out, a.missing_text.as_deref()); + } +} + +/// Spill descriptor-dictionary reuse: same codec, byte-slice framing +pub(crate) fn encode_descriptor_bytes(out: &mut Vec, d: &RelDescriptor) { + encode_descriptor(out, d); +} + +/// Returns decoded descriptor + consumed byte count +pub(crate) fn decode_descriptor_bytes( + buf: &[u8], +) -> std::result::Result<(RelDescriptor, usize), String> { + let mut cur = Cur::new(buf, "spill", 0); + let d = decode_descriptor(&mut cur).map_err(|e| e.to_string())?; + Ok((d, cur.pos)) +} + +fn encode_opt_attnums(out: &mut Vec, nums: Option<&[i16]>) { + match nums { + None => push_u8(out, 0), + Some(nums) => { + push_u8(out, 1); + encode_attnums(out, nums); + } + } +} + +fn encode_attnums(out: &mut Vec, nums: &[i16]) { + push_u32(out, nums.len() as u32); + for n in nums { + push_i16(out, *n); + } +} + +fn push_frame(out: &mut Vec, body: &[u8]) { + push_u32(out, body.len() as u32); + push_u32(out, crc32c::crc32c(body)); + out.extend_from_slice(body); +} + +// ── decoding ──────────────────────────────────────────────────────── + +struct Cur<'a> { + buf: &'a [u8], + pos: usize, + file: &'static str, + base: u64, +} + +impl<'a> Cur<'a> { + fn new(buf: &'a [u8], file: &'static str, base: u64) -> Self { + Self { + buf, + pos: 0, + file, + base, + } + } + + fn corrupt(&self, detail: impl Into) -> DescLogError { + DescLogError::Corrupt { + file: self.file, + offset: self.base + self.pos as u64, + detail: detail.into(), + } + } + + fn need(&mut self, n: usize) -> Result<&'a [u8]> { + if self.pos + n > self.buf.len() { + return Err(self.corrupt(format!( + "short read: need {n}, have {}", + self.buf.len() - self.pos + ))); + } + let s = &self.buf[self.pos..self.pos + n]; + self.pos += n; + Ok(s) + } + + fn u8(&mut self) -> Result { + Ok(self.need(1)?[0]) + } + fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.need(2)?.try_into().unwrap())) + } + fn i16(&mut self) -> Result { + Ok(i16::from_le_bytes(self.need(2)?.try_into().unwrap())) + } + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.need(4)?.try_into().unwrap())) + } + fn i32(&mut self) -> Result { + Ok(i32::from_le_bytes(self.need(4)?.try_into().unwrap())) + } + fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes(self.need(8)?.try_into().unwrap())) + } + fn string(&mut self) -> Result { + let n = self.u32()? as usize; + let bs = self.need(n)?; + String::from_utf8(bs.to_vec()).map_err(|e| self.corrupt(format!("utf8: {e}"))) + } + fn opt_string(&mut self) -> Result> { + Ok(match self.u8()? { + 0 => None, + _ => Some(self.string()?), + }) + } + fn charlike(&mut self) -> Result { + Ok(self.u8()? as char) + } + fn boolish(&mut self) -> Result { + Ok(self.u8()? != 0) + } +} + +/// Parse header + frames into `index`. Returns the byte offset after the +/// last good frame. `repairable = true` (tail): a frame torn at EOF stops +/// the load there for the caller to truncate; interior corruption still +/// fails. `repairable = false` (ckpt): any malformation fails. +fn load_frames( + bytes: &[u8], + file: &'static str, + identity: &DescLogIdentity, + index: &mut Index, + repairable: bool, +) -> Result { + let mut cur = Cur::new(bytes, file, 0); + let magic = cur.need(2)?; + if magic != MAGIC { + return Err(DescLogError::Corrupt { + file, + offset: 0, + detail: format!("bad magic {magic:02x?}"), + }); + } + let version = cur.u16()?; + if version != VERSION { + return Err(DescLogError::Version(version)); + } + check_identity(&mut cur, identity)?; + let mut good = cur.pos as u64; + loop { + let frame_start = cur.pos; + if frame_start == bytes.len() { + break; + } + let torn = |detail: String| -> Result { + if repairable { + Ok(good) + } else { + Err(DescLogError::Corrupt { + file, + offset: frame_start as u64, + detail, + }) + } + }; + if bytes.len() - frame_start < 8 { + return torn("torn frame header".into()); + } + let len = u32::from_le_bytes(bytes[frame_start..frame_start + 4].try_into().unwrap()); + let crc = u32::from_le_bytes(bytes[frame_start + 4..frame_start + 8].try_into().unwrap()); + let body_start = frame_start + 8; + let body_end = body_start as u64 + len as u64; + if body_end > bytes.len() as u64 { + return torn(format!("frame len {len} past EOF")); + } + if len > MAX_FRAME { + // Fits in the file yet exceeds any real batch: garbage with + // data after it, not a torn append + return Err(DescLogError::Corrupt { + file, + offset: frame_start as u64, + detail: format!("frame len {len} exceeds bound"), + }); + } + let body = &bytes[body_start..body_end as usize]; + if crc32c::crc32c(body) != crc { + if body_end == bytes.len() as u64 { + return torn("crc mismatch on final frame".into()); + } + return Err(DescLogError::Corrupt { + file, + offset: frame_start as u64, + detail: "crc mismatch on interior frame".into(), + }); + } + let mut body_cur = Cur::new(body, file, body_start as u64); + match body_cur.u8()? { + TAG_META => { + index.covered_through = body_cur.u64()?; + index.floor_at_write = body_cur.u64()?; + } + TAG_BATCH => { + let batch = decode_batch(&mut body_cur)?; + match index.batches.get(&batch.captured_at) { + // ckpt/tail overlap from a crash between GC's ckpt + // write and tail truncate + Some(existing) if **existing == batch => {} + Some(existing) => { + return Err(DescLogError::Corrupt { + file, + offset: frame_start as u64, + detail: format!( + "batch at captured_at {:#x} diverges from earlier copy \ + (digest {:#010x} vs {:#010x})", + batch.captured_at, + batch.digest(), + existing.digest(), + ), + }); + } + None => index.insert_batch(Arc::new(batch)), + } + } + other => { + return Err(DescLogError::Corrupt { + file, + offset: body_start as u64, + detail: format!("unknown frame tag {other}"), + }); + } + } + cur.pos = body_end as usize; + good = body_end; + } + Ok(good) +} + +fn check_identity(cur: &mut Cur<'_>, ours: &DescLogIdentity) -> Result<()> { + let log = DescLogIdentity { + pg_major: cur.u32()?, + system_id: cur.string()?, + timeline: cur.u32()?, + db_oid: cur.u32()?, + wal_seg_size: cur.u32()?, + }; + let mismatch = |field: &'static str, log: String, ours: String| { + Err(DescLogError::ForeignLog { field, log, ours }) + }; + if log.pg_major != ours.pg_major { + return mismatch( + "pg_major", + log.pg_major.to_string(), + ours.pg_major.to_string(), + ); + } + if log.system_id != ours.system_id { + return mismatch("system_id", log.system_id, ours.system_id.clone()); + } + if log.timeline != ours.timeline { + return mismatch( + "timeline", + log.timeline.to_string(), + ours.timeline.to_string(), + ); + } + if log.db_oid != ours.db_oid { + return mismatch("db_oid", log.db_oid.to_string(), ours.db_oid.to_string()); + } + if log.wal_seg_size != ours.wal_seg_size { + return mismatch( + "wal_seg_size", + log.wal_seg_size.to_string(), + ours.wal_seg_size.to_string(), + ); + } + Ok(()) +} + +fn decode_batch(cur: &mut Cur<'_>) -> Result { + let captured_at = cur.u64()?; + let commit_lsn = cur.u64()?; + let n = cur.u32()? as usize; + let mut entries = Vec::with_capacity(n); + for _ in 0..n { + let valid_from = cur.u64()?; + let oid = cur.u32()?; + let rfn = RelFileNode { + spc_node: cur.u32()?, + db_node: cur.u32()?, + rel_node: cur.u32()?, + }; + let value = match cur.u8()? { + 0 => LogValue::Present(Arc::new(decode_descriptor(cur)?)), + 1 => LogValue::Dropped, + 2 => LogValue::Retired, + other => return Err(cur.corrupt(format!("unknown entry kind {other}"))), + }; + entries.push(Arc::new(LogEntry { + valid_from, + oid, + rfn, + value, + })); + } + let n = cur.u32()? as usize; + let mut ambiguities = Vec::with_capacity(n); + for _ in 0..n { + let scope = match cur.u8()? { + 0 => AmbiguityScope::Rfn(RelFileNode { + spc_node: cur.u32()?, + db_node: cur.u32()?, + rel_node: cur.u32()?, + }), + 1 => AmbiguityScope::Oid(cur.u32()?), + 2 => AmbiguityScope::Database(cur.u32()?), + other => return Err(cur.corrupt(format!("unknown ambiguity scope {other}"))), + }; + let from_lsn = cur.u64()?; + let through_lsn = cur.u64()?; + let reason = match cur.u8()? { + 1 => AmbiguityReason::UnknownMutationPosition, + other => return Err(cur.corrupt(format!("unknown ambiguity reason {other}"))), + }; + ambiguities.push(Arc::new(Ambiguity { + scope, + from_lsn, + through_lsn, + reason, + })); + } + let n = cur.u32()? as usize; + let mut observations = Vec::with_capacity(n); + for _ in 0..n { + let oid = match cur.u8()? { + 0 => None, + _ => Some(cur.u32()?), + }; + let rfn = match cur.u8()? { + 0 => None, + _ => Some(RelFileNode { + spc_node: cur.u32()?, + db_node: cur.u32()?, + rel_node: cur.u32()?, + }), + }; + let first_touch_lsn = cur.u64()?; + let smgr_create_lsn = match cur.u8()? { + 0 => None, + _ => Some(cur.u64()?), + }; + let kind = match cur.u8()? { + 0 => ObservationKind::AffectedOid, + 1 => ObservationKind::SmgrCreate, + 2 => ObservationKind::FullScan, + other => return Err(cur.corrupt(format!("unknown observation kind {other}"))), + }; + observations.push(RelationObservation { + oid, + rfn, + first_touch_lsn, + smgr_create_lsn, + kind, + }); + } + Ok(BatchRecord { + captured_at, + commit_lsn, + observations, + ambiguities, + entries, + }) +} + +fn decode_descriptor(cur: &mut Cur<'_>) -> Result { + let rfn = RelFileNode { + spc_node: cur.u32()?, + db_node: cur.u32()?, + rel_node: cur.u32()?, + }; + let oid = cur.u32()?; + let toast_oid = cur.u32()?; + let namespace_oid = cur.u32()?; + let namespace = cur.string()?; + let name = cur.string()?; + let kind = cur.charlike()?; + let persistence = cur.charlike()?; + let replident = match cur.u8()? { + 0 => ReplIdent::Default { + pk_attnums: decode_opt_attnums(cur)?, + }, + 1 => ReplIdent::Nothing, + 2 => ReplIdent::Full { + pk_attnums: decode_opt_attnums(cur)?, + }, + 3 => ReplIdent::UsingIndex { + index_oid: cur.u32()?, + key_attnums: decode_attnums(cur)?, + }, + other => return Err(cur.corrupt(format!("unknown replident tag {other}"))), + }; + let n = cur.u32()? as usize; + let mut attributes = Vec::with_capacity(n); + for _ in 0..n { + attributes.push(RelAttr { + attnum: cur.i16()?, + name: cur.string()?, + type_oid: cur.u32()?, + typmod: cur.i32()?, + not_null: cur.boolish()?, + dropped: cur.boolish()?, + type_name: cur.string()?, + type_byval: cur.boolish()?, + type_len: cur.i16()?, + type_align: cur.charlike()?, + type_storage: cur.charlike()?, + missing_text: cur.opt_string()?, + }); + } + Ok(RelDescriptor { + rfn, + oid, + toast_oid, + namespace_oid, + rel_name: RelName::new(&namespace, &name), + kind, + persistence, + replident, + attributes, + }) +} + +fn decode_opt_attnums(cur: &mut Cur<'_>) -> Result>> { + Ok(match cur.u8()? { + 0 => None, + _ => Some(decode_attnums(cur)?), + }) +} + +fn decode_attnums(cur: &mut Cur<'_>) -> Result> { + let n = cur.u32()? as usize; + let mut out = Vec::with_capacity(n); + for _ in 0..n { + out.push(cur.i16()?); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ident() -> DescLogIdentity { + DescLogIdentity { + pg_major: 17, + system_id: "7300000000000000001".into(), + timeline: 1, + db_oid: 5, + wal_seg_size: 16 * 1024 * 1024, + } + } + + fn rfn(rel_node: u32) -> RelFileNode { + RelFileNode { + spc_node: 1663, + db_node: 5, + rel_node, + } + } + + fn desc(oid: Oid, rel_node: u32, extra_col: bool) -> Arc { + let mut attributes = vec![ + RelAttr { + attnum: 1, + name: "id".into(), + type_oid: 20, + typmod: -1, + not_null: true, + dropped: false, + type_name: "int8".into(), + type_byval: true, + type_len: 8, + type_align: 'd', + type_storage: 'p', + missing_text: None, + }, + // Dropped slot: physical layout retained, type link severed + RelAttr { + attnum: 2, + name: "........pg.dropped.2........".into(), + type_oid: 0, + typmod: -1, + not_null: false, + dropped: true, + type_name: String::new(), + type_byval: false, + type_len: -1, + type_align: 'i', + type_storage: 'x', + missing_text: None, + }, + ]; + if extra_col { + attributes.push(RelAttr { + attnum: 3, + name: "extra".into(), + type_oid: 23, + typmod: -1, + not_null: false, + dropped: false, + type_name: "int4".into(), + type_byval: true, + type_len: 4, + type_align: 'i', + type_storage: 'p', + missing_text: Some("42".into()), + }); + } + Arc::new(RelDescriptor { + rfn: rfn(rel_node), + oid, + toast_oid: 0, + namespace_oid: 2200, + rel_name: RelName::new("public", &format!("t{oid}")), + kind: 'r', + persistence: 'p', + replident: if extra_col { + ReplIdent::UsingIndex { + index_oid: 900, + key_attnums: vec![1, 3], + } + } else { + ReplIdent::Default { + pk_attnums: Some(vec![1]), + } + }, + attributes, + }) + } + + fn present(valid_from: u64, d: &Arc) -> Arc { + Arc::new(LogEntry { + valid_from, + oid: d.oid, + rfn: d.rfn, + value: LogValue::Present(d.clone()), + }) + } + + fn tombstone(valid_from: u64, oid: Oid, rel_node: u32, value: LogValue) -> Arc { + Arc::new(LogEntry { + valid_from, + oid, + rfn: rfn(rel_node), + value, + }) + } + + fn batch(captured_at: u64, entries: Vec>) -> BatchRecord { + BatchRecord { + captured_at, + commit_lsn: 0, + observations: Vec::new(), + ambiguities: Vec::new(), + entries, + } + } + + fn amb(scope: AmbiguityScope, from_lsn: u64, through_lsn: u64) -> Arc { + Arc::new(Ambiguity { + scope, + from_lsn, + through_lsn, + reason: AmbiguityReason::UnknownMutationPosition, + }) + } + + async fn open(dir: &Path) -> DescriptorLog { + DescriptorLog::open(dir, ident()).await.unwrap() + } + + #[tokio::test(flavor = "current_thread")] + async fn round_trip_reopen() { + let tmp = tempfile::tempdir().unwrap(); + let d1 = desc(101, 6001, false); + let d2 = desc(101, 6001, true); + { + let log = open(tmp.path()).await; + assert!(log.is_empty()); + log.seed(batch(100, vec![present(90, &d1)]), 100) + .await + .unwrap(); + log.append_batch(batch(200, vec![present(180, &d2)])) + .await + .unwrap(); + // Stub: boundary with no shape change + log.append_batch(batch(300, vec![])).await.unwrap(); + } + let log = open(tmp.path()).await; + assert!(!log.is_empty()); + assert_eq!(log.covered_through(), 100); + assert_eq!(log.head(), 300); + match log.descriptor_at(rfn(6001), 179) { + LookupResult::Present(d) => assert_eq!(d, d1), + other => panic!("expected d1, got {other:?}"), + } + match log.descriptor_at(rfn(6001), 180) { + LookupResult::Present(d) => assert_eq!(d, d2), + other => panic!("expected d2, got {other:?}"), + } + match log.descriptor_by_oid_at(101, u64::MAX) { + LookupResult::Present(d) => assert_eq!(d, d2), + other => panic!("expected d2 by oid, got {other:?}"), + } + assert_eq!(log.descriptor_at(rfn(6001), 89), LookupResult::NotCovered); + assert!(log.batch_at(300).unwrap().entries.is_empty()); + assert!(log.batch_at(150).is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn rotation_retires_old_rfn_interval() { + let tmp = tempfile::tempdir().unwrap(); + let old = desc(42, 7000, false); + let mut new_inner = (*desc(42, 7001, false)).clone(); + new_inner.rfn = rfn(7001); + let new = Arc::new(new_inner); + let log = open(tmp.path()).await; + log.seed(batch(100, vec![present(90, &old)]), 100) + .await + .unwrap(); + log.append_batch(batch( + 210, + vec![ + tombstone(200, 42, 7000, LogValue::Retired), + present(200, &new), + ], + )) + .await + .unwrap(); + assert!(matches!( + log.descriptor_at(rfn(7000), 199), + LookupResult::Present(_) + )); + assert_eq!(log.descriptor_at(rfn(7000), 200), LookupResult::Retired); + assert!(matches!( + log.descriptor_at(rfn(7001), 200), + LookupResult::Present(_) + )); + assert_eq!(log.descriptor_at(rfn(7001), 199), LookupResult::NotCovered); + // by_oid stays Present across the rotation + assert!(matches!( + log.descriptor_by_oid_at(42, 300), + LookupResult::Present(_) + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn dropped_tombstone_and_foreign_db() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(55, 8000, false); + let log = open(tmp.path()).await; + log.seed(batch(100, vec![present(90, &d)]), 100) + .await + .unwrap(); + log.append_batch(batch( + 200, + vec![tombstone(200, 55, 8000, LogValue::Dropped)], + )) + .await + .unwrap(); + assert!(matches!( + log.descriptor_at(rfn(8000), 150), + LookupResult::Present(_) + )); + assert_eq!(log.descriptor_at(rfn(8000), 200), LookupResult::Dropped); + assert_eq!(log.descriptor_by_oid_at(55, 200), LookupResult::Dropped); + let foreign = RelFileNode { + spc_node: 1663, + db_node: 999, + rel_node: 8000, + }; + assert_eq!(log.descriptor_at(foreign, 150), LookupResult::ForeignDb); + // Shared-catalog db_node 0 is local, not foreign + let shared = RelFileNode { + spc_node: 1664, + db_node: 0, + rel_node: 8000, + }; + assert_eq!(log.descriptor_at(shared, 150), LookupResult::NotCovered); + } + + #[tokio::test(flavor = "current_thread")] + async fn same_db_rel_across_tablespaces_stay_distinct() { + let tmp = tempfile::tempdir().unwrap(); + // Post-wraparound relfilenumber reuse: two live rels share + // (db, rel), differ only in tablespace + let a = desc(42, 7000, false); + let mut b_inner = (*desc(84, 7000, true)).clone(); + b_inner.rfn.spc_node = 9999; + let b = Arc::new(b_inner); + let log = open(tmp.path()).await; + // One batch, shared valid_from + captured_at: insertion order must + // not decide which relation a locator resolves to + log.append_batch(batch(100, vec![present(90, &a), present(90, &b)])) + .await + .unwrap(); + match log.descriptor_at(a.rfn, 150) { + LookupResult::Present(d) => assert_eq!(d, a), + other => panic!("expected a, got {other:?}"), + } + match log.descriptor_at(b.rfn, 150) { + LookupResult::Present(d) => assert_eq!(d, b), + other => panic!("expected b, got {other:?}"), + } + // Tombstoning one chain leaves the sibling untouched + log.append_batch(batch( + 200, + vec![Arc::new(LogEntry { + valid_from: 200, + oid: 84, + rfn: b.rfn, + value: LogValue::Dropped, + })], + )) + .await + .unwrap(); + assert_eq!(log.descriptor_at(b.rfn, 200), LookupResult::Dropped); + match log.descriptor_at(a.rfn, 200) { + LookupResult::Present(d) => assert_eq!(d, a), + other => panic!("expected a to survive b's drop, got {other:?}"), + } + } + + #[tokio::test(flavor = "current_thread")] + async fn torn_tail_truncates_and_appends_resume() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(60, 8100, false); + { + let log = open(tmp.path()).await; + log.append_batch(batch(100, vec![present(90, &d)])) + .await + .unwrap(); + log.append_batch(batch(200, vec![])).await.unwrap(); + } + let path = tmp.path().join(TAIL_FILE); + let len = std::fs::metadata(&path).unwrap().len(); + let f = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); + f.set_len(len - 3).unwrap(); + drop(f); + { + let log = open(tmp.path()).await; + assert!(log.batch_at(100).is_some()); + assert!(log.batch_at(200).is_none(), "torn frame dropped"); + log.append_batch(batch(300, vec![])).await.unwrap(); + } + let log = open(tmp.path()).await; + assert!(log.batch_at(100).is_some()); + assert!(log.batch_at(300).is_some()); + } + + #[tokio::test(flavor = "current_thread")] + async fn interior_corruption_fails_closed() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(61, 8200, false); + { + let log = open(tmp.path()).await; + log.append_batch(batch(100, vec![present(90, &d)])) + .await + .unwrap(); + log.append_batch(batch(200, vec![])).await.unwrap(); + } + let path = tmp.path().join(TAIL_FILE); + let mut bytes = std::fs::read(&path).unwrap(); + // Flip one byte inside the first frame's body (past header + frame + // header) while the second frame follows intact + let hdr = encode_header(&ident()).len(); + bytes[hdr + 8 + 4] ^= 0xff; + std::fs::write(&path, &bytes).unwrap(); + let err = DescriptorLog::open(tmp.path(), ident()).await.unwrap_err(); + assert!( + matches!(err, DescLogError::Corrupt { .. }), + "expected Corrupt, got {err:?}" + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn identity_and_version_mismatch_fatal() { + let tmp = tempfile::tempdir().unwrap(); + { + let log = open(tmp.path()).await; + log.append_batch(batch(100, vec![])).await.unwrap(); + } + let mut other = ident(); + other.db_oid = 6; + let err = DescriptorLog::open(tmp.path(), other).await.unwrap_err(); + assert!(matches!( + err, + DescLogError::ForeignLog { + field: "db_oid", + .. + } + )); + + let path = tmp.path().join(TAIL_FILE); + let mut bytes = std::fs::read(&path).unwrap(); + bytes[2] = 9; // version u16 LE low byte + std::fs::write(&path, &bytes).unwrap(); + let err = DescriptorLog::open(tmp.path(), ident()).await.unwrap_err(); + assert!(matches!(err, DescLogError::Version(9))); + } + + #[tokio::test(flavor = "current_thread")] + async fn append_idempotent_divergent_fails() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(70, 8300, false); + let log = open(tmp.path()).await; + log.append_batch(batch(100, vec![present(90, &d)])) + .await + .unwrap(); + // Byte-identical replay no-ops + log.append_batch(batch(100, vec![present(90, &d)])) + .await + .unwrap(); + assert_eq!(log.batch_at(100).unwrap().entries.len(), 1); + let err = log.append_batch(batch(100, vec![])).await.unwrap_err(); + assert!(matches!(err, DescLogError::Corrupt { .. })); + } + + #[tokio::test(flavor = "current_thread")] + async fn gc_supersede_keeps_active_at_floor() { + let tmp = tempfile::tempdir().unwrap(); + let d1 = desc(80, 8400, false); + let d2 = desc(80, 8400, true); + let log = open(tmp.path()).await; + log.append_batch(batch(20, vec![present(10, &d1)])) + .await + .unwrap(); + log.append_batch(batch(60, vec![present(50, &d2)])) + .await + .unwrap(); + log.force_gc(100).await.unwrap(); + assert_eq!(log.floor_at_write(), 100); + // Active-at-floor survives, superseded predecessor dropped + match log.descriptor_at(rfn(8400), 150) { + LookupResult::Present(d) => assert_eq!(d, d2), + other => panic!("expected d2, got {other:?}"), + } + assert_eq!(log.descriptor_at(rfn(8400), 20), LookupResult::NotCovered); + // Batches at/below floor exist only as entry carriers + assert!(log.batch_at(20).is_none()); + assert!(log.batch_at(60).is_some()); + // Survives reopen from ckpt + drop(log); + let log = open(tmp.path()).await; + match log.descriptor_at(rfn(8400), 150) { + LookupResult::Present(d) => assert_eq!(d, d2), + other => panic!("expected d2 post-reopen, got {other:?}"), + } + } + + /// `maybe_gc`'s threshold counts entries compaction would actually drop, + /// not everything sitting below the floor: a log of live relations + /// declines, superseding them all trips it and retains one per key + #[tokio::test(flavor = "current_thread")] + async fn maybe_gc_counts_only_dead_entries() { + let tmp = tempfile::tempdir().unwrap(); + let log = open(tmp.path()).await; + let live: Vec<_> = (0..GC_DEAD_ENTRIES as u32) + .map(|i| desc(9000 + i, 90000 + i, false)) + .collect(); + log.append_batch(batch( + 20, + live.iter().map(|d| present(10, d)).collect::>(), + )) + .await + .unwrap(); + let entries = log.gauges().0; + assert_eq!(entries, GC_DEAD_ENTRIES as u64); + assert!(!log.maybe_gc(u64::MAX).await.unwrap(), "nothing superseded"); + assert_eq!(log.gauges().0, entries, "declined GC leaves the index"); + log.append_batch(batch( + 40, + live.iter() + .map(|d| present(30, &desc(d.oid, d.rfn.rel_node, true))) + .collect::>(), + )) + .await + .unwrap(); + assert!(log.maybe_gc(u64::MAX).await.unwrap(), "predecessors dead"); + assert_eq!( + log.gauges().0, + GC_DEAD_ENTRIES as u64, + "one active entry per key retained" + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn gc_drop_before_floor_no_resurrection() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(81, 8500, false); + let log = open(tmp.path()).await; + log.append_batch(batch(20, vec![present(10, &d)])) + .await + .unwrap(); + log.append_batch(batch(60, vec![tombstone(60, 81, 8500, LogValue::Dropped)])) + .await + .unwrap(); + log.force_gc(100).await.unwrap(); + // Dropped at floor: whole chain gone, absence is inactive — never + // the earlier Present + assert_eq!(log.descriptor_at(rfn(8500), 300), LookupResult::NotCovered); + assert_eq!(log.descriptor_by_oid_at(81, 300), LookupResult::NotCovered); + drop(log); + let log = open(tmp.path()).await; + assert_eq!(log.descriptor_at(rfn(8500), 300), LookupResult::NotCovered); + // Filenode reuse starts a fresh chain + let mut reuse_inner = (*desc(82, 8500, false)).clone(); + reuse_inner.rfn = rfn(8500); + let reuse = Arc::new(reuse_inner); + log.append_batch(batch(400, vec![present(390, &reuse)])) + .await + .unwrap(); + assert!(matches!( + log.descriptor_at(rfn(8500), 400), + LookupResult::Present(_) + )); + assert_eq!(log.descriptor_at(rfn(8500), 380), LookupResult::NotCovered); + } + + #[tokio::test(flavor = "current_thread")] + async fn gc_retired_rotation_drops_old_chain() { + let tmp = tempfile::tempdir().unwrap(); + let old = desc(83, 8600, false); + let mut new_inner = (*desc(83, 8601, false)).clone(); + new_inner.rfn = rfn(8601); + let new = Arc::new(new_inner); + let log = open(tmp.path()).await; + log.append_batch(batch(20, vec![present(10, &old)])) + .await + .unwrap(); + log.append_batch(batch( + 60, + vec![ + tombstone(50, 83, 8600, LogValue::Retired), + present(50, &new), + ], + )) + .await + .unwrap(); + log.force_gc(100).await.unwrap(); + assert_eq!(log.descriptor_at(rfn(8600), 300), LookupResult::NotCovered); + assert!(matches!( + log.descriptor_at(rfn(8601), 300), + LookupResult::Present(_) + )); + assert!(matches!( + log.descriptor_by_oid_at(83, 300), + LookupResult::Present(_) + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn gc_keeps_batches_above_floor_whole() { + let tmp = tempfile::tempdir().unwrap(); + let d1 = desc(84, 8700, false); + let d2 = desc(84, 8700, true); + let log = open(tmp.path()).await; + log.append_batch(batch(20, vec![present(10, &d1)])) + .await + .unwrap(); + log.append_batch(batch(150, vec![present(140, &d2)])) + .await + .unwrap(); + log.append_batch(batch(200, vec![])).await.unwrap(); + log.force_gc(100).await.unwrap(); + // Above floor: batch + stub retained for boot replay + assert_eq!(log.batch_at(150).unwrap().entries.len(), 1); + assert!(log.batch_at(200).unwrap().entries.is_empty()); + // At-floor active entry retained below + match log.descriptor_at(rfn(8700), 100) { + LookupResult::Present(d) => assert_eq!(d, d1), + other => panic!("expected d1 at floor, got {other:?}"), + } + } + + #[tokio::test(flavor = "current_thread")] + async fn predecessor_before_uses_history_not_head() { + let tmp = tempfile::tempdir().unwrap(); + let v1 = desc(85, 8800, false); + let v2 = desc(85, 8800, true); + let mut v3_inner = (*desc(85, 8800, true)).clone(); + v3_inner.rel_name = RelName::new("public", "renamed"); + let v3 = Arc::new(v3_inner); + let log = open(tmp.path()).await; + log.append_batch(batch(100, vec![present(90, &v1)])) + .await + .unwrap(); + log.append_batch(batch(200, vec![present(190, &v2)])) + .await + .unwrap(); + log.append_batch(batch(300, vec![present(290, &v3)])) + .await + .unwrap(); + // Replaying the middle boundary diffs against v1 even though the + // loaded head is v3 + let pred = log.predecessor_before(85, 200).unwrap(); + assert_eq!(pred.value, LogValue::Present(v1.clone())); + assert!(log.predecessor_before(85, 100).is_none()); + let pred = log.predecessor_before(85, 300).unwrap(); + assert_eq!(pred.value, LogValue::Present(v2.clone())); + } + + #[tokio::test(flavor = "current_thread")] + async fn seed_rules() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(86, 8900, false); + let log = open(tmp.path()).await; + log.seed(batch(500, vec![present(400, &d)]), 500) + .await + .unwrap(); + assert_eq!(log.covered_through(), 500); + // Seed entry answers the aligned-prefix re-read + assert!(matches!( + log.descriptor_at(rfn(8900), 400), + LookupResult::Present(_) + )); + let err = log.seed(batch(600, vec![]), 600).await.unwrap_err(); + assert!(matches!(err, DescLogError::Corrupt { .. })); + drop(log); + let log = open(tmp.path()).await; + assert_eq!(log.covered_through(), 500); + } + + #[tokio::test(flavor = "current_thread")] + async fn oversize_frame_len_fails_when_interior() { + let tmp = tempfile::tempdir().unwrap(); + { + let log = open(tmp.path()).await; + log.append_batch(batch(100, vec![])).await.unwrap(); + } + let path = tmp.path().join(TAIL_FILE); + let mut bytes = std::fs::read(&path).unwrap(); + let hdr = encode_header(&ident()).len(); + // Claimed len fits nothing sane but extends past EOF → torn, repaired + bytes[hdr..hdr + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + std::fs::write(&path, &bytes).unwrap(); + let log = open(tmp.path()).await; + assert!(log.batch_at(100).is_none()); + drop(log); + // Oversize len with enough trailing bytes to "fit" → interior garbage + let mut bytes = std::fs::read(&path).unwrap(); + bytes.truncate(hdr); + let huge = (MAX_FRAME + 1) as usize; + bytes.extend_from_slice(&(MAX_FRAME + 1).to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.resize(hdr + 8 + huge, 0); + std::fs::write(&path, &bytes).unwrap(); + let err = DescriptorLog::open(tmp.path(), ident()).await.unwrap_err(); + assert!(matches!(err, DescLogError::Corrupt { .. })); + } + + #[tokio::test(flavor = "current_thread")] + async fn ckpt_tail_overlap_dedupes_divergent_fails() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(87, 9000, false); + let b = batch(100, vec![present(90, &d)]); + { + let log = open(tmp.path()).await; + log.append_batch(b.clone()).await.unwrap(); + log.force_gc(0).await.unwrap(); + } + // Simulate crash between GC's ckpt write and tail truncate: the + // batch reappears in the tail + let path = tmp.path().join(TAIL_FILE); + let mut bytes = std::fs::read(&path).unwrap(); + push_frame(&mut bytes, &encode_batch(&Arc::new(b))); + std::fs::write(&path, &bytes).unwrap(); + { + let log = open(tmp.path()).await; + assert_eq!(log.batch_at(100).unwrap().entries.len(), 1); + } + // Divergent duplicate fails closed + let mut bytes = std::fs::read(&path).unwrap(); + push_frame(&mut bytes, &encode_batch(&Arc::new(batch(100, vec![])))); + std::fs::write(&path, &bytes).unwrap(); + let err = DescriptorLog::open(tmp.path(), ident()).await.unwrap_err(); + assert!(matches!(err, DescLogError::Corrupt { .. })); + } + + #[tokio::test(flavor = "current_thread")] + async fn present_oids_and_active_set() { + let tmp = tempfile::tempdir().unwrap(); + let d1 = desc(88, 9100, false); + let d2 = desc(89, 9101, false); + let log = open(tmp.path()).await; + log.append_batch(batch(100, vec![present(90, &d1), present(90, &d2)])) + .await + .unwrap(); + log.append_batch(batch( + 200, + vec![tombstone(200, 89, 9101, LogValue::Dropped)], + )) + .await + .unwrap(); + let mut oids = log.present_oids(); + oids.sort_unstable(); + assert_eq!(oids, vec![88]); + assert_eq!(log.active_present_at(150).len(), 2); + assert_eq!(log.active_present_at(250).len(), 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn ambiguity_precedes_chain_half_open() { + let tmp = tempfile::tempdir().unwrap(); + let d1 = desc(90, 9200, false); + let d2 = desc(90, 9200, true); + let log = open(tmp.path()).await; + log.append_batch(batch(100, vec![present(90, &d1)])) + .await + .unwrap(); + // Uncertain in-place change over [200, 300): final version Present + // at commit next_lsn for future rows + let mut b = batch(300, vec![present(300, &d2)]); + b.ambiguities + .push(amb(AmbiguityScope::Rfn(rfn(9200)), 200, 300)); + log.append_batch(b).await.unwrap(); + match log.descriptor_at(rfn(9200), 199) { + LookupResult::Present(d) => assert_eq!(d, d1), + other => panic!("expected d1 before interval, got {other:?}"), + } + // [from, through): from covered, through not + assert!(matches!( + log.descriptor_at(rfn(9200), 200), + LookupResult::Ambiguous(_) + )); + assert!(matches!( + log.descriptor_at(rfn(9200), 299), + LookupResult::Ambiguous(_) + )); + match log.descriptor_at(rfn(9200), 300) { + LookupResult::Present(d) => assert_eq!(d, d2), + other => panic!("expected d2 at through_lsn, got {other:?}"), + } + // Chain entry inside the interval stays shadowed even though it + // exists: ambiguity wins over Present + assert!(matches!( + log.descriptor_at(rfn(9200), 250), + LookupResult::Ambiguous(_) + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn ambiguity_scopes_oid_and_database() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(91, 9300, false); + let log = open(tmp.path()).await; + let mut b = batch(100, vec![present(90, &d)]); + b.ambiguities.push(amb(AmbiguityScope::Oid(91), 200, 250)); + b.ambiguities + .push(amb(AmbiguityScope::Database(5), 400, 450)); + log.append_batch(b).await.unwrap(); + // Oid scope hits by-oid lookup only + assert!(matches!( + log.descriptor_by_oid_at(91, 220), + LookupResult::Ambiguous(_) + )); + assert!(matches!( + log.descriptor_at(rfn(9300), 220), + LookupResult::Present(_) + )); + // Database scope hits both + assert!(matches!( + log.descriptor_at(rfn(9300), 420), + LookupResult::Ambiguous(_) + )); + assert!(matches!( + log.descriptor_by_oid_at(91, 420), + LookupResult::Ambiguous(_) + )); + // Shared-catalog db_node 0 skips database-scoped ambiguity + let shared = RelFileNode { + spc_node: 1664, + db_node: 0, + rel_node: 9300, + }; + assert_eq!(log.descriptor_at(shared, 420), LookupResult::NotCovered); + // Foreign db answers before ambiguity + let foreign = RelFileNode { + spc_node: 1663, + db_node: 999, + rel_node: 9300, + }; + assert_eq!(log.descriptor_at(foreign, 420), LookupResult::ForeignDb); + } + + #[tokio::test(flavor = "current_thread")] + async fn ambiguity_round_trip_and_idempotent_append() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(92, 9400, false); + let mut b = batch(100, vec![present(90, &d)]); + b.ambiguities + .push(amb(AmbiguityScope::Rfn(rfn(9400)), 50, 100)); + { + let log = open(tmp.path()).await; + log.append_batch(b.clone()).await.unwrap(); + // Byte-identical replay no-ops + log.append_batch(b.clone()).await.unwrap(); + // Same entries, divergent ambiguities fail closed + let mut divergent = b.clone(); + divergent.ambiguities = vec![amb(AmbiguityScope::Rfn(rfn(9400)), 50, 120)]; + let err = log.append_batch(divergent).await.unwrap_err(); + assert!(matches!(err, DescLogError::Corrupt { .. })); + } + let log = open(tmp.path()).await; + assert!(matches!( + log.descriptor_at(rfn(9400), 60), + LookupResult::Ambiguous(a) if a.through_lsn == 100 + )); + assert_eq!( + log.stats_handle() + .lookups_ambiguous + .load(std::sync::atomic::Ordering::Relaxed), + 1, + ); + assert!(matches!( + log.descriptor_at(rfn(9400), 100), + LookupResult::Present(_) + )); + assert_eq!(log.batch_at(100).unwrap().ambiguities.len(), 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn gc_keeps_ambiguity_spanning_floor() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(93, 9500, false); + let log = open(tmp.path()).await; + let mut b = batch(60, vec![present(10, &d)]); + // Wholly below floor: dead, no lookup lands under it + b.ambiguities + .push(amb(AmbiguityScope::Rfn(rfn(9500)), 20, 40)); + // Spans floor: still answers lookups at/above it + b.ambiguities + .push(amb(AmbiguityScope::Rfn(rfn(9500)), 80, 150)); + log.append_batch(b).await.unwrap(); + log.force_gc(100).await.unwrap(); + assert!(matches!( + log.descriptor_at(rfn(9500), 120), + LookupResult::Ambiguous(a) if a.from_lsn == 80 + )); + assert_eq!(log.batch_at(60).unwrap().ambiguities.len(), 1); + drop(log); + let log = open(tmp.path()).await; + assert!(matches!( + log.descriptor_at(rfn(9500), 120), + LookupResult::Ambiguous(_) + )); + match log.descriptor_at(rfn(9500), 160) { + LookupResult::Present(d2) => assert_eq!(d2, d), + other => panic!("expected retained Present past interval, got {other:?}"), + } + } + + #[tokio::test(flavor = "current_thread")] + async fn intersecting_is_overlap_under_every_key() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(96, 9800, false); + let log = open(tmp.path()).await; + let mut b = batch(400, vec![present(400, &d)]); + // Sibling pair one physical verdict publishes + b.ambiguities + .push(amb(AmbiguityScope::Rfn(rfn(9800)), 200, 400)); + b.ambiguities.push(amb(AmbiguityScope::Oid(96), 200, 400)); + b.ambiguities + .push(amb(AmbiguityScope::Database(5), 900, 950)); + log.append_batch(b).await.unwrap(); + let hits = |from, through| { + log.ambiguities_intersecting(rfn(9800), Some(96), from, through) + .len() + }; + // Both keys answer; the caller narrows per record + assert_eq!(hits(250, 500), 2, "rfn + oid siblings"); + assert_eq!( + log.ambiguities_intersecting(rfn(9800), None, 250, 500) + .len(), + 1, + "oid omitted: rfn key only", + ); + // Interval opening below the range still covers records inside it + assert_eq!(hits(300, 500), 2); + // Half-open both sides: touching at an endpoint is not overlap + assert_eq!(hits(400, 500), 0, "range starts at through_lsn"); + assert_eq!(hits(100, 200), 0, "range ends at from_lsn"); + assert_eq!(hits(900, 910), 1, "database scope reaches the rfn path"); + // Foreign db never fences + let foreign = RelFileNode { + spc_node: 1663, + db_node: 999, + rel_node: 9800, + }; + assert!( + log.ambiguities_intersecting(foreign, Some(96), 0, u64::MAX) + .is_empty() + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn sibling_scopes_survive_gc_and_reopen() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(97, 9900, false); + { + let log = open(tmp.path()).await; + let mut b = batch(300, vec![present(300, &d)]); + b.ambiguities + .push(amb(AmbiguityScope::Rfn(rfn(9900)), 100, 300)); + b.ambiguities.push(amb(AmbiguityScope::Oid(97), 100, 300)); + log.append_batch(b).await.unwrap(); + log.force_gc(200).await.unwrap(); + assert_eq!(log.rfn_ambiguities(rfn(9900)).len(), 1); + assert_eq!(log.oid_ambiguities(97).len(), 1); + } + let log = open(tmp.path()).await; + assert!(matches!( + log.descriptor_at(rfn(9900), 250), + LookupResult::Ambiguous(_) + )); + assert!(matches!( + log.descriptor_by_oid_at(97, 250), + LookupResult::Ambiguous(_) + )); + assert_eq!(log.batch_at(300).unwrap().ambiguities.len(), 2); + } + + #[tokio::test(flavor = "current_thread")] + async fn covering_scan_answers_past_stale_prefix() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(98, 10000, false); + let log = open(tmp.path()).await; + let mut b = batch(5000, vec![present(5000, &d)]); + // Long run of intervals that closed before the query point + for i in 0..64u64 { + b.ambiguities.push(amb( + AmbiguityScope::Rfn(rfn(10000)), + 100 + i * 10, + 105 + i * 10, + )); + } + b.ambiguities + .push(amb(AmbiguityScope::Rfn(rfn(10000)), 2000, 3000)); + // Opens after the query point: prefix bound must exclude it + b.ambiguities + .push(amb(AmbiguityScope::Rfn(rfn(10000)), 4000, 4500)); + log.append_batch(b).await.unwrap(); + assert!(matches!( + log.descriptor_at(rfn(10000), 2500), + LookupResult::Ambiguous(a) if a.from_lsn == 2000 + )); + assert_eq!( + log.descriptor_at(rfn(10000), 3500), + LookupResult::NotCovered + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn old_format_rejected_both_files() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(95, 9700, false); + { + let log = open(tmp.path()).await; + log.append_batch(batch(100, vec![present(90, &d)])) + .await + .unwrap(); + log.force_gc(0).await.unwrap(); + } + // Pre-ambiguity v1 and pre-fence v2 dirs both reject at open, ckpt or + // tail alike; the explicit epoch reset (--ignore-cursor / + // re-bootstrap) is the only way forward, never silent translation — + // a v2 interval was published for read-identical drift too and would + // fence rows v2 decoded fine + for file in [TAIL_FILE, CKPT_FILE] { + let path = tmp.path().join(file); + let orig = std::fs::read(&path).unwrap(); + for stale in [1u8, 2] { + let mut bytes = orig.clone(); + bytes[2] = stale; // version u16 LE low byte + std::fs::write(&path, &bytes).unwrap(); + let err = DescriptorLog::open(tmp.path(), ident()).await.unwrap_err(); + assert!( + matches!(err, DescLogError::Version(v) if v == u16::from(stale)), + "{file} v{stale}" + ); + } + std::fs::write(&path, &orig).unwrap(); + } + let log = open(tmp.path()).await; + assert!(!log.is_empty()); + } + + #[tokio::test(flavor = "current_thread")] + async fn evidence_round_trip_divergence_fails() { + let tmp = tempfile::tempdir().unwrap(); + let d = desc(94, 9600, false); + let mut b = batch(200, vec![present(190, &d)]); + b.commit_lsn = 180; + b.observations = vec![ + RelationObservation { + oid: Some(94), + rfn: None, + first_touch_lsn: 185, + smgr_create_lsn: None, + kind: ObservationKind::AffectedOid, + }, + RelationObservation { + oid: None, + rfn: Some(rfn(9600)), + first_touch_lsn: 185, + smgr_create_lsn: Some(186), + kind: ObservationKind::SmgrCreate, + }, + ]; + { + let log = open(tmp.path()).await; + log.append_batch(b.clone()).await.unwrap(); + // Identical evidence no-ops + log.append_batch(b.clone()).await.unwrap(); + } + let log = open(tmp.path()).await; + let stored = log.batch_at(200).unwrap(); + assert_eq!(*stored, b); + assert_eq!(stored.digest(), b.digest()); + // Same final entries, divergent evidence fails closed + let mut divergent = b.clone(); + divergent.observations[0].first_touch_lsn = 184; + assert_ne!(divergent.digest(), b.digest()); + let err = log.append_batch(divergent).await.unwrap_err(); + assert!(matches!(err, DescLogError::Corrupt { .. })); + let mut divergent = b.clone(); + divergent.commit_lsn = 181; + let err = log.append_batch(divergent).await.unwrap_err(); + assert!(matches!(err, DescLogError::Corrupt { .. })); + } +} diff --git a/src/catalog/mod.rs b/src/catalog/mod.rs index ab7bae7e..e617b549 100644 --- a/src/catalog/mod.rs +++ b/src/catalog/mod.rs @@ -1,3 +1,5 @@ +pub mod compat; +pub mod desc_log; pub mod shadow; pub mod shadow_catalog; pub mod type_bridge; diff --git a/src/catalog/shadow_catalog.rs b/src/catalog/shadow_catalog.rs index 35da2a7c..12fec99d 100644 --- a/src/catalog/shadow_catalog.rs +++ b/src/catalog/shadow_catalog.rs @@ -1,45 +1,28 @@ -//! Shadow PG catalog cache. +//! Shadow PG SQL client for descriptor capture + name-keyed resolution. //! -//! [`ShadowCatalog::relation_at`] resolution: -//! 1. Block until shadow's `pg_last_wal_replay_lsn()` ≥ `at_lsn`, so -//! shadow's catalog reflects every catalog write source issued at or -//! before that LSN -//! 2. Check cache keyed by `(rfn, generation)` -//! 3. Miss → resolve `rfn` via `pg_relation_filenode(oid)` (uniform over -//! mapped catalogs and regular tables), fan-out to pg_attribute + -//! pg_type + pg_namespace -//! -//! Generation invalidation: a -//! [`CatalogTracker`](crate::filter::catalog_tracker::CatalogTracker) bumps a shared -//! `AtomicU64` on every catalog-touching record. Lookups read it at entry and -//! invalidate in-line BEFORE the cache check, so a DDL in the same batch as a -//! dependent heap INSERT can't race past the cache. -//! -//! Concurrency: methods take `&mut self`; cache state could be interior-mutable -//! (RwLock + atomics) but that refactor is deferred until a real lookup-rate hot -//! path exists. Concurrent callers wrap in `Arc>`. +//! Decode never reads this: interval-scoped answers come from the durable +//! [`DescriptorLog`](crate::catalog::desc_log::DescriptorLog), which capture +//! populates from here at catalog boundaries (batched +//! [`ShadowCatalog::fetch_descriptors_batch`] / +//! [`ShadowCatalog::fetch_all_descriptors`] round trips). Name-keyed reads +//! ([`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`. -//! Cross-user-database replay needs one cache per DB; out of scope. -use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use backon::{ExponentialBuilder, RetryableWithContext}; use thiserror::Error; -use tokio::sync::{Mutex, mpsc}; use tokio_postgres::types::{Oid, PgLsn, ToSql}; use tokio_postgres::{Client, NoTls, Row}; -use tracing::Instrument; use walrus::pg::walparser::RelFileNode; -use crate::pg::parse_array_one_element; #[cfg(test)] use crate::pg::socket_conninfo; -use crate::schema::{RelAttr, RelDescriptor, RelName, ReplIdent, SchemaEvent, compute_schema_diff}; +use crate::schema::{RelAttr, RelDescriptor, RelName, ReplIdent}; #[derive(Debug, Error)] pub enum CatalogError { @@ -67,11 +50,9 @@ pub type Result = std::result::Result; pub struct ShadowCatalogConfig { /// `pg_last_wal_replay_lsn()` poll interval pub replay_poll: Duration, - /// [`ShadowCatalog::relation_at`] gives up after this if shadow hasn't - /// passed `at_lsn`; also bounds [`with_transient_retry`]'s window + /// [`ShadowCatalog::wait_for_replay`] gives up after this; also bounds + /// [`with_transient_retry`]'s window pub replay_timeout: Duration, - /// `None` = unbounded - pub max_entries: Option, pub reconnect_backoff_initial: Duration, pub reconnect_backoff_max: Duration, } @@ -85,89 +66,26 @@ impl Default for ShadowCatalogConfig { // miss bounded by SQL round-trip cost instead replay_poll: Duration::from_millis(1), replay_timeout: Duration::from_secs(30), - max_entries: Some(4096), reconnect_backoff_initial: Duration::from_millis(100), reconnect_backoff_max: Duration::from_secs(1), } } } -struct CacheEntry { - generation: u64, - insert_order: u64, - desc: Arc, -} - #[derive(Debug, Default, Clone)] pub struct ShadowCatalogStats { - pub hits: u64, - pub misses: u64, pub fetches: u64, - pub generation_bumps: u64, pub replay_waits: u64, - pub evictions: u64, - /// Records whose `db_node` is neither shadow DB nor shared catalog (0). - /// Physical replication ships the whole cluster's WAL; rejected before the - /// filenode query - pub foreign_db_skips: u64, pub reconnects: u64, } -/// FIFO insert-order index for cache eviction: O(log n) `pop_first` instead of -/// an O(n) `min_by_key` scan. Re-inserting a cached filenode rotates via -/// `unregister` + `register` to stay 1:1 with `by_filenode`. -#[derive(Debug, Default)] -struct EvictionIndex { - by_order: BTreeMap, - next: u64, -} - -impl EvictionIndex { - fn register(&mut self, rfn: RelFileNode) -> u64 { - self.next += 1; - self.by_order.insert(self.next, rfn); - self.next - } - - fn unregister(&mut self, prev_order: u64) { - self.by_order.remove(&prev_order); - } - - fn pop_oldest(&mut self) -> Option { - self.by_order.pop_first().map(|(_, r)| r) - } - - #[cfg(test)] - fn len(&self) -> usize { - self.by_order.len() - } -} - pub struct ShadowCatalog { client: Client, conninfo: String, config: ShadowCatalogConfig, - generation: u64, - by_filenode: HashMap, - by_oid: HashMap, - /// Last-seen descriptor per oid, retained across generation bumps (which - /// only logically invalidate by_filenode/by_oid). Source of truth for the - /// shape `compute_schema_diff` diffs against. - prev_known: HashMap>, - eviction: EvictionIndex, last_replay_lsn: Option, - /// Bumped by the decoder worker off each record's - /// [`CatalogSignal`](crate::record::CatalogSignal) and by - /// mapping writes / SIGHUP reload; an advance triggers `invalidate`. - /// `None` standalone (tests, batch tools). - invalidation_epoch: Option>, - /// Latest epoch already folded into `generation` - last_seen_epoch: u64, - /// `None` keeps the producer side a no-op (standalone catalog, pre-applicator tests) - event_tx: Option>, - /// DB oid this client is connected to. Rejects foreign-DB filenodes before - /// the relfilenode query (relfilenodes unique only within a DB). Survives - /// `reconnect` since `conninfo` pins the DB. + /// DB oid this client is connected to; survives `reconnect` since + /// `conninfo` pins the DB current_db_oid: Option, stats: ShadowCatalogStats, } @@ -205,105 +123,12 @@ impl ShadowCatalog { client, conninfo: conninfo.to_string(), config, - generation: 0, - by_filenode: HashMap::new(), - by_oid: HashMap::new(), - prev_known: HashMap::new(), - eviction: EvictionIndex::default(), last_replay_lsn: None, - invalidation_epoch: None, - last_seen_epoch: 0, - event_tx: None, current_db_oid: None, stats: ShadowCatalogStats::default(), }) } - /// Install the schema-event sink, returning its `Receiver`. Single - /// subscriber by design; a later `subscribe` overwrites the prior sink. - pub fn subscribe(&mut self) -> mpsc::UnboundedReceiver { - let (tx, rx) = mpsc::unbounded_channel(); - self.event_tx = Some(tx); - rx - } - - /// Bootstrap fan-out: resolve every relation in the named (`auto_create`) - /// namespaces, emit `Added` for each unseen oid. Idempotent across daemon - /// restarts via the applicator's `CREATE TABLE IF NOT EXISTS`. Other - /// namespaces stay undisclosed until first WAL touch. Each owner's toast - /// rel resolves alongside it, same rationale as [`Self::seed_baseline`]. - pub async fn seed_from_source(&mut self, namespaces: &[String]) -> Result { - if namespaces.is_empty() { - return Ok(0); - } - let rows = self - .query_retry( - "SELECT c.oid::oid \ - FROM pg_class c \ - JOIN pg_namespace n ON n.oid = c.relnamespace \ - WHERE n.nspname = ANY($1::text[]) \ - AND c.relkind IN ('r', 'p')", - &[&namespaces], - ) - .await?; - let mut added = 0usize; - for row in rows { - let oid: Oid = row.get(0); - if self.prev_known.contains_key(&oid) { - continue; - } - // Regular path so `Added` flows through record_descriptor into the - // subscriber queue. - match self.relation_by_oid(oid).await { - Ok(_) => added += 1, - Err(CatalogError::NotFoundByOid(_)) => continue, - Err(e) => return Err(e), - } - self.toast_descriptor_for(oid).await?; - } - Ok(added) - } - - /// Warm `prev_known` with operator-pinned relations' boot-time shape, so a - /// first post-start `ALTER`/`RENAME`/`DROP` diffs as `Changed` (→ CH - /// `ALTER`) not cold `Added` — which the applicator skips for pinned dests, - /// leaving CH a column behind. Must run BEFORE [`Self::subscribe`]: - /// `send_event` no-ops while `event_tx` is `None`, so seeding emits no - /// `Added` and does zero CH work at boot. - /// - /// `rel_names` are pinned-mapping keys, resolved via pg_class⋈pg_namespace - /// (miss skipped, defensive: preflight guarantees existence). Oids already - /// in `prev_known` skipped → idempotent across `--start-lsn` resume. - /// - /// Records the *full* source descriptor, not the pinned subset, so unmapped - /// columns sit in the baseline as "excluded", never "added since". See - /// `plans/future/pinned_ddl_baseline.md`. - /// - /// Each owner's toast rel is resolved too: [`Self::sweep_dropped`] probes - /// only `prev_known`, so a post-restart owner DROP retires the CH chunk - /// mirror only when the toast oid was seeded; cold, the mirror leaks until - /// a chunk decode re-warms it. - pub async fn seed_baseline(&mut self, rel_names: &[RelName]) -> Result { - let mut seeded = 0usize; - for rel in rel_names { - let Some(oid) = self.oid_by_name(rel).await? else { - continue; - }; - if self.prev_known.contains_key(&oid) { - continue; - } - match self.relation_by_oid(oid).await { - Ok(_) => seeded += 1, - Err(CatalogError::NotFoundByOid(_)) => continue, - Err(e) => return Err(e), - } - // Toast oid into prev_known too, else sweep_dropped can't - // surface it after restart - self.toast_descriptor_for(oid).await?; - } - Ok(seeded) - } - async fn oid_by_name(&mut self, rel: &RelName) -> Result> { let (ns, name): (&str, &str) = (&rel.namespace, &rel.name); let row = self @@ -318,9 +143,9 @@ impl ShadowCatalog { } /// Resolve a relation name to its current source descriptor via shadow's - /// `pg_class` (same path as [`Self::seed_baseline`]), or `None` when the - /// rel isn't known yet — the forward-declared case the per-table opt-in - /// dispatch parks in `pending_decl`. + /// `pg_class`, or `None` when the rel isn't known yet — the + /// forward-declared case the per-table opt-in dispatch parks in + /// `pending_decl`. pub async fn descriptor_by_name( &mut self, rel: &RelName, @@ -328,11 +153,7 @@ impl ShadowCatalog { let Some(oid) = self.oid_by_name(rel).await? else { return Ok(None); }; - match self.relation_by_oid(oid).await { - Ok(desc) => Ok(Some(desc)), - Err(CatalogError::NotFoundByOid(_)) => Ok(None), - Err(e) => Err(e), - } + Ok(self.fetch_by_oid(oid).await?.map(Arc::new)) } /// Resolve a table's TOAST relation descriptor (`pg_class.reltoastrelid` @@ -350,155 +171,16 @@ impl ShadowCatalog { if toast_oid == 0 { return Ok(None); } - match self.relation_by_oid(toast_oid).await { - Ok(desc) => Ok(Some(desc)), - Err(CatalogError::NotFoundByOid(_)) => Ok(None), - Err(e) => Err(e), - } - } - - /// Emit `Dropped` for an oid seen via pg_class `heap_delete`. Returns - /// `false` for an oid the catalog never saw (CH never learned it either, - /// nothing for the applicator to do). - fn emit_dropped(&mut self, oid: Oid) -> bool { - let Some(prev) = self.prev_known.remove(&oid) else { - return false; - }; - self.by_oid.remove(&oid); - // Filenode entry stays; evicted lazily on next access - self.send_event(SchemaEvent::Dropped { - oid, - rel_name: prev.rel_name.clone(), - }); - true - } - - /// Poll-based DROP TABLE discovery: any `prev_known` oid no longer in - /// shadow's pg_class gets a `Dropped` event and is removed. Returns count - /// surfaced. - /// - /// Needed because the natural path (decoder sees pg_class `heap_delete`) - /// doesn't fire for `relreplident = 'n'` system catalogs — PG omits the old - /// tuple from WAL, so the dropped oid is unextractable. Callers gate on - /// [`PendingSweeps`](crate::filter::catalog_tracker::PendingSweeps): the sweep - /// runs only at the commit of an xact that wrote pg_class heap_delete, - /// after `wait_for_replay` past that commit, so the drop is MVCC-visible. - /// No internal throttle: epoch/generation comparisons here re-created the - /// consume-early race (an earlier sweep folding a later DROP's bump made - /// the drop's own commit no-op and lost the event). - /// - /// A shadow replaying ahead can surface a LATER armed xact's drop at an - /// earlier armed commit; the later xact's own sweep then finds nothing - /// and no-ops. Benign: attribution shifts to an earlier commit LSN, - /// never lost, and the end state (relation dropped) is identical. - pub async fn sweep_dropped(&mut self) -> Result { - if self.prev_known.is_empty() { - return Ok(0); - } - let known: Vec = self.prev_known.keys().copied().collect(); - let rows = self - .query_retry( - "SELECT oid::oid FROM pg_class WHERE oid = ANY($1::oid[])", - &[&known], - ) - .await?; - let alive: std::collections::HashSet = rows.iter().map(|r| r.get(0)).collect(); - let mut emitted = 0usize; - for oid in known { - if !alive.contains(&oid) && self.emit_dropped(oid) { - emitted += 1; - } - } - Ok(emitted) - } - - fn send_event(&self, ev: SchemaEvent) { - if let Some(tx) = &self.event_tx { - // Send fails only if the receiver dropped (daemon shutdown) - let _ = tx.send(ev); - } - } - - fn record_descriptor(&mut self, new: &Arc) { - // Indexes have no CH lifecycle: keep them out of the event stream + - // drop sweep, else an owner DROP surfaces its pg_toast index as a - // second pg_toast `Dropped` and double-counts the mirror retire - if matches!(new.kind, 'i' | 'I') { - return; - } - let oid = new.oid; - match self.prev_known.get(&oid).cloned() { - None => { - self.send_event(SchemaEvent::Added { desc: new.clone() }); - } - Some(old) => { - if Arc::ptr_eq(&old, new) { - return; - } - let diff = compute_schema_diff(&old, new); - if !diff.is_empty() { - self.send_event(SchemaEvent::Changed { - old, - new: new.clone(), - diff, - }); - } - } - } - self.prev_known.insert(oid, new.clone()); - } - - /// Pass the same `Arc` clone as the decoder sink's - /// `with_catalog_signals` and the DDL applicator's - /// `with_invalidation_epoch`. - pub fn set_invalidation_epoch(&mut self, epoch: Arc) { - // Adopt current value as seen so a non-zero epoch (catalog opened - // mid-stream) doesn't spuriously invalidate on first lookup - self.last_seen_epoch = epoch.load(Ordering::Acquire); - self.invalidation_epoch = Some(epoch); - } - - fn drain_invalidations(&mut self) { - let Some(e) = &self.invalidation_epoch else { - return; - }; - let cur = e.load(Ordering::Acquire); - if cur != self.last_seen_epoch { - self.last_seen_epoch = cur; - self.invalidate(); - } - } - - pub fn generation(&self) -> u64 { - self.generation - } - - /// Lock-free handle to the invalidation epoch (bumped on DDL); `None` - /// standalone. The decode pool flushes its cache on a bump. - pub fn invalidation_epoch_handle(&self) -> Option> { - self.invalidation_epoch.clone() + Ok(self.fetch_by_oid(toast_oid).await?.map(Arc::new)) } pub fn stats(&self) -> &ShadowCatalogStats { &self.stats } - pub fn cached(&self) -> usize { - self.by_filenode.len() - } - - /// Bump generation, marking every cached entry stale. Lazy eviction (old - /// entries retained until next access), cheap regardless of commit size. - pub fn invalidate(&mut self) -> u64 { - self.generation = self.generation.wrapping_add(1); - self.stats.generation_bumps += 1; - self.generation - } - /// Rebuild the client from stashed `conninfo`. One-shot; retry via - /// [`with_transient_retry`]. Bumps generation because catalog mutations may - /// have landed in the down window without an upstream `invalidate`; resets - /// `last_replay_lsn` since a restarted instance's replay LSN starts fresh. + /// [`with_transient_retry`]. Resets `last_replay_lsn` since a restarted + /// instance's replay LSN starts fresh. async fn reconnect(&mut self) -> Result<()> { let (client, conn) = tokio_postgres::connect(&self.conninfo, NoTls).await?; tokio::spawn(async move { @@ -506,8 +188,6 @@ impl ShadowCatalog { }); self.client = client; self.stats.reconnects += 1; - self.generation = self.generation.wrapping_add(1); - self.stats.generation_bumps += 1; self.last_replay_lsn = None; Ok(()) } @@ -583,194 +263,6 @@ impl ShadowCatalog { } } - /// Look up by `RelFileNode`, gated on shadow replay past `at_lsn`. Decoder's - /// standard call shape. `at_lsn = 0` skips the gate (caller proved freshness - /// otherwise, e.g. preceding `wait_for_replay`). - pub async fn relation_at( - &mut self, - rfn: RelFileNode, - at_lsn: u64, - ) -> Result> { - self.drain_invalidations(); - if at_lsn > 0 { - // `replay.wait` — the poll loop blocking on the shadow PG - // replaying up to `at_lsn`. Nests under `catalog.gate` when - // the decoder instruments this call; this is where a stalled - // shadow shows up (up to the 30s replay timeout). `target_lsn` - // is the LSN we need; `replay_lsn` is where the shadow actually - // is once the wait returns (== target on the cached fast path). - let replay_span = trace_span!( - !tracing::Span::current().is_none(), - "replay.wait", - target_lsn = at_lsn, - replay_lsn = tracing::field::Empty, - ); - let replayed = self - .wait_for_replay(at_lsn) - .instrument(replay_span.clone()) - .await?; - replay_span.record("replay_lsn", replayed); - // Re-check after the await: a concurrent mapping write / - // SIGHUP reload can have bumped the epoch while - // wait_for_replay yielded. - self.drain_invalidations(); - } - if let Some(entry) = self.by_filenode.get(&rfn) - && entry.generation == self.generation - { - self.stats.hits += 1; - return Ok(entry.desc.clone()); - } - self.stats.misses += 1; - // `descriptor.fetch` — the pg_class/pg_attribute round-trip to the - // shadow PG, only on a cache miss. Sibling of `replay.wait`. - let desc = self - .fetch_by_filenode(rfn) - .instrument(trace_span!( - !tracing::Span::current().is_none(), - "descriptor.fetch", - spc_node = rfn.spc_node, - db_node = rfn.db_node, - rel_node = rfn.rel_node, - )) - .await? - .ok_or(CatalogError::NotFoundByFilenode(rfn))?; - Ok(self.insert(desc)) - } - - /// Filenode resolution for the async decode pool. Serves the inline path's - /// ([`BufferingDecoderSink`](crate::xact::xact_buffer::BufferingDecoderSink)) - /// cached entry at ANY generation; fetches transiently (no cache write, no - /// events) only when the filenode is absent. - /// - /// The pool lags asynchronously, so a worker can fall behind a DDL that - /// bumped generation. Re-resolving via [`Self::relation_at`] is wrong: - /// - /// * ADD COLUMN keeps the filenode: a lagging fetch reads pre-DDL shape but - /// inserts it at the post-DDL generation, poisoning the entry so the - /// inline path's later post-DDL resolution hits and never fires `Changed` - /// (barrier never reaches CH). - /// * TRUNCATE rotates the filenode: once replayed, the old filenode no - /// longer resolves, so a fetch returns `None` and pre-TRUNCATE rows fail — - /// the cached entry is their only surviving descriptor. - /// - /// Reusing the cached shape keeps schema-change detection and cache - /// maintenance solely on the inline path. - pub async fn relation_at_pooled( - &mut self, - rfn: RelFileNode, - at_lsn: u64, - ) -> Result> { - self.drain_invalidations(); - if at_lsn > 0 { - self.wait_for_replay(at_lsn).await?; - self.drain_invalidations(); - } - if let Some(entry) = self.by_filenode.get(&rfn) { - self.stats.hits += 1; - return Ok(entry.desc.clone()); - } - // Absent (never inline-resolved, or evicted): fetch transiently, no - // insert, to keep the pool out of cache state - self.stats.misses += 1; - let desc = self - .fetch_by_filenode(rfn) - .await? - .ok_or(CatalogError::NotFoundByFilenode(rfn))?; - Ok(Arc::new(desc)) - } - - /// Look up by oid, no replay gate (oid-only references: xact records, - /// shared-catalog probes). - pub async fn relation_by_oid(&mut self, oid: Oid) -> Result> { - self.drain_invalidations(); - if let Some(entry) = self.by_oid.get(&oid) - && entry.generation == self.generation - { - self.stats.hits += 1; - return Ok(entry.desc.clone()); - } - self.stats.misses += 1; - let desc = self - .fetch_by_oid(oid) - .await? - .ok_or(CatalogError::NotFoundByOid(oid))?; - Ok(self.insert(desc)) - } - - fn insert(&mut self, desc: RelDescriptor) -> Arc { - let arc = Arc::new(desc); - if let Some(prev) = self.by_filenode.get(&arc.rfn) { - self.eviction.unregister(prev.insert_order); - } - let order = self.eviction.register(arc.rfn); - let entry = CacheEntry { - generation: self.generation, - insert_order: order, - desc: arc.clone(), - }; - self.by_filenode.insert( - arc.rfn, - CacheEntry { - generation: entry.generation, - insert_order: entry.insert_order, - desc: arc.clone(), - }, - ); - self.by_oid.insert(arc.oid, entry); - self.record_descriptor(&arc); - self.evict_if_over_cap(); - arc - } - - fn evict_if_over_cap(&mut self) { - let Some(cap) = self.config.max_entries else { - return; - }; - while self.by_filenode.len() > cap { - let Some(victim_rfn) = self.eviction.pop_oldest() else { - break; - }; - if let Some(e) = self.by_filenode.remove(&victim_rfn) { - self.by_oid.remove(&e.desc.oid); - self.stats.evictions += 1; - } - } - } - - async fn fetch_by_filenode(&mut self, rfn: RelFileNode) -> Result> { - // Reject foreign-DB filenodes first: relfilenode is unique only per - // database (regardless of tablespace, so spc_node need not match), so a - // foreign db_node's rel_node could collide with a local relation. - // db_node 0 = shared catalog (visible from any DB), let through. - if is_foreign_db(rfn.db_node, self.current_db_oid().await?) { - self.stats.foreign_db_skips += 1; - return Err(CatalogError::ForeignDatabase(rfn)); - } - self.stats.fetches += 1; - // pg_relation_filenode(oid) abstracts mapped (pg_filenode.map) vs - // unmapped (pg_class.relfilenode) - 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 \ - FROM pg_class c \ - JOIN pg_namespace n ON n.oid = c.relnamespace \ - WHERE pg_relation_filenode(c.oid) = $1 \ - LIMIT 1", - &[&rfn.rel_node], - ) - .await?; - let Some(row) = row else { return Ok(None) }; - Ok(Some(self.descriptor_from_row(&row, rfn).await?)) - } - async fn fetch_by_oid(&mut self, oid: Oid) -> Result> { self.stats.fetches += 1; let row = self @@ -783,7 +275,10 @@ impl ShadowCatalog { c.relkind::text, \ c.relpersistence::text, \ c.relreplident::text, \ - c.reltablespace::oid, \ + 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 \ @@ -792,8 +287,8 @@ impl ShadowCatalog { ) .await?; let Some(row) = row else { return Ok(None) }; - let spc_node: Oid = row.get(7); - let rel_node: Oid = row.get(8); + 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, @@ -803,9 +298,9 @@ impl ShadowCatalog { Ok(Some(self.descriptor_from_row(&row, rfn).await?)) } - /// Build from a pg_class⋈pg_namespace row whose first 7 columns are + /// Build from a pg_class⋈pg_namespace row whose first 8 columns are /// (oid, relnamespace, nspname, relname, relkind, relpersistence, - /// relreplident), paired with a resolved `rfn`. + /// 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); @@ -814,11 +309,13 @@ impl ShadowCatalog { 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, @@ -828,6 +325,46 @@ impl ShadowCatalog { }) } + /// 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. + 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 + } + + /// 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 + } + + async fn fetch_descriptor_rows( + &mut self, + sql: &str, + params: &[&(dyn ToSql + Sync)], + ) -> Result<(u64, Vec)> { + 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)?); + } + 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); + } + Ok((replay_lsn, out)) + } + async fn fetch_replident(&mut self, c: char, rel_oid: Oid) -> Result { match c { 'd' => { @@ -895,50 +432,17 @@ impl ShadowCatalog { // `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( - "SELECT \ - a.attnum::int2, \ - a.attname::text, \ - a.atttypid::oid, \ - a.atttypmod::int4, \ - a.attnotnull::bool, \ - a.attisdropped::bool, \ - t.typname::text, \ - t.typbyval::bool, \ - t.typlen::int2, \ - t.typalign::text, \ - t.typstorage::text, \ - CASE WHEN a.atthasmissing THEN a.attmissingval::text END \ - FROM pg_attribute a \ - JOIN pg_type t ON t.oid = a.atttypid \ - WHERE a.attrelid = $1 AND a.attnum >= 1 \ - ORDER BY a.attnum", - &[&rel_oid], - ) - .await?; - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - let raw_missing: Option = row.get(11); - out.push(RelAttr { - attnum: row.get(0), - name: row.get(1), - type_oid: row.get(2), - typmod: row.get(3), - not_null: row.get(4), - dropped: row.get(5), - type_name: row.get(6), - type_byval: row.get(7), - type_len: row.get(8), - type_align: one_char(row.get::<_, String>(9), "typalign")?, - type_storage: one_char(row.get::<_, String>(10), "typstorage")?, - missing_text: raw_missing.as_deref().and_then(parse_array_one_element), - }); - } - Ok(out) + 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() } - async fn current_database_oid(&mut self) -> Result { + pub async fn current_database_oid(&mut self) -> Result { let row = self .query_one_retry( "SELECT oid::oid FROM pg_database WHERE datname = current_database()", @@ -960,31 +464,6 @@ impl ShadowCatalog { } } -/// Resolve a WAL-observed filenode under the shared mutex. -pub async fn resolve_at( - catalog: &Mutex, - rfn: RelFileNode, - at_lsn: u64, -) -> Result> { - let mut cat = catalog.lock().await; - cat.relation_at(rfn, at_lsn).await -} - -/// [`resolve_at`] for the async decode pool. See -/// [`ShadowCatalog::relation_at_pooled`]. -pub async fn resolve_at_pooled( - catalog: &Mutex, - rfn: RelFileNode, - at_lsn: u64, -) -> Result> { - let mut cat = catalog.lock().await; - cat.relation_at_pooled(rfn, at_lsn).await -} - -/// Strip + dequote the single element of a PG array literal `{val}`, recovering -/// `attmissingval[1]`'s typoutput form. -/// -/// PG array_out quoting (`src/backend/utils/adt/arrayfuncs.c`): fn one_char(s: String, what: &str) -> Result { let mut chars = s.chars(); match (chars.next(), chars.next()) { @@ -1035,11 +514,198 @@ fn is_transient(err: &CatalogError) -> bool { matches!(err, CatalogError::Pg(_)) } -/// True when `db_node` is neither the connected shadow DB nor a shared catalog -/// (db_node 0). Such filenodes come from other DBs in the cluster's physical WAL -/// and must not resolve locally. -fn is_foreign_db(db_node: Oid, current_db_oid: Oid) -> bool { - db_node != 0 && db_node != current_db_oid +/// 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')") +}); + +/// 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); + let replident = replident_from_parts( + replident_char, + oid, + pk_attnums, + ri_index_oid.zip(ri_attnums), + )?; + let attributes = attrs_from_arrays(row)?; + Ok(RelDescriptor { + rfn: RelFileNode { + spc_node, + db_node, + rel_node, + }, + oid, + toast_oid, + namespace_oid, + rel_name: RelName::new(&namespace_name, &name), + kind, + persistence, + replident, + attributes, + }) +} + +fn replident_from_parts( + c: char, + rel_oid: Oid, + pk_attnums: Option>, + using_index: Option<(Oid, Vec)>, +) -> Result { + match c { + 'd' => Ok(ReplIdent::Default { pk_attnums }), + 'n' => Ok(ReplIdent::Nothing), + 'f' => Ok(ReplIdent::Full { pk_attnums }), + 'i' => { + let (index_oid, key_attnums) = using_index.ok_or_else(|| { + CatalogError::Parse(format!( + "relreplident='i' but no pg_index row with indisreplident=true for relation {rel_oid}", + )) + })?; + Ok(ReplIdent::UsingIndex { + index_oid, + key_attnums, + }) + } + other => Err(CatalogError::Parse(format!( + "unknown relreplident {other:?} (expected one of d/n/f/i)", + ))), + } +} + +/// 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)] @@ -1049,48 +715,6 @@ mod tests { use super::*; - #[test] - fn foreign_db_predicate() { - // db_node 0 = shared catalog, never foreign - assert!(!is_foreign_db(0, 16384)); - assert!(!is_foreign_db(16384, 16384)); - assert!(is_foreign_db(16385, 16384)); - } - - #[test] - fn parse_array_one_element_scalars() { - assert_eq!(parse_array_one_element("{7}").as_deref(), Some("7")); - assert_eq!(parse_array_one_element("{t}").as_deref(), Some("t")); - assert_eq!(parse_array_one_element("{3.14}").as_deref(), Some("3.14"),); - assert_eq!( - parse_array_one_element("{-9223372036854775808}").as_deref(), - Some("-9223372036854775808"), - ); - } - - #[test] - fn parse_array_one_element_quoted_text() { - assert_eq!( - parse_array_one_element("{\"hello\"}").as_deref(), - Some("hello"), - ); - assert_eq!( - parse_array_one_element("{\"hello, world\"}").as_deref(), - Some("hello, world"), - ); - assert_eq!( - parse_array_one_element("{\"a\\\"b\"}").as_deref(), - Some("a\"b"), - ); - } - - #[test] - fn parse_array_one_element_empty_and_null() { - assert!(parse_array_one_element("{}").is_none()); - assert!(parse_array_one_element("{NULL}").is_none()); - assert!(parse_array_one_element("nope").is_none()); - } - #[test] fn one_char_accepts_single() { assert_eq!(one_char("r".into(), "relkind").unwrap(), 'r'); @@ -1116,7 +740,6 @@ mod tests { fn config_default_is_sane() { let c = ShadowCatalogConfig::default(); assert!(c.replay_poll < c.replay_timeout); - assert!(c.max_entries.is_some()); assert!(c.reconnect_backoff_initial < c.reconnect_backoff_max); } @@ -1131,40 +754,6 @@ mod tests { })); } - fn rfn(rel: u32) -> RelFileNode { - RelFileNode { - spc_node: 1663, - db_node: 5, - rel_node: rel, - } - } - - #[test] - fn eviction_index_pops_oldest_first() { - let mut ix = EvictionIndex::default(); - let o1 = ix.register(rfn(10)); - let o2 = ix.register(rfn(20)); - let o3 = ix.register(rfn(30)); - assert!(o1 < o2 && o2 < o3); - assert_eq!(ix.len(), 3); - assert_eq!(ix.pop_oldest(), Some(rfn(10))); - assert_eq!(ix.pop_oldest(), Some(rfn(20))); - assert_eq!(ix.pop_oldest(), Some(rfn(30))); - assert_eq!(ix.pop_oldest(), None); - } - - #[test] - fn eviction_index_unregister_drops_stale_order() { - let mut ix = EvictionIndex::default(); - let o1 = ix.register(rfn(10)); - ix.register(rfn(20)); - ix.unregister(o1); - let _ = ix.register(rfn(10)); - assert_eq!(ix.len(), 2); - assert_eq!(ix.pop_oldest(), Some(rfn(20))); - assert_eq!(ix.pop_oldest(), Some(rfn(10))); - } - #[tokio::test(flavor = "current_thread")] async fn with_transient_retry_returns_immediately_on_success() { let calls = Arc::new(AtomicUsize::new(0)); @@ -1183,123 +772,6 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 1); } - fn mk_attr(attnum: i16, name: &str, oid: Oid, not_null: bool) -> RelAttr { - RelAttr { - attnum, - name: name.into(), - type_oid: oid, - typmod: -1, - not_null, - dropped: false, - type_name: "test".into(), - type_byval: true, - type_len: 4, - type_align: 'i', - type_storage: 'p', - missing_text: None, - } - } - - fn mk_desc(oid: Oid, attrs: Vec) -> RelDescriptor { - RelDescriptor { - rfn: rfn(oid), - oid, - namespace_oid: 2200, - rel_name: RelName::new("public", &format!("t{oid}")), - kind: 'r', - persistence: 'p', - replident: ReplIdent::Default { pk_attnums: None }, - attributes: attrs, - } - } - - #[test] - fn schema_diff_detects_added_columns() { - let old = mk_desc(16400, vec![mk_attr(1, "id", 23, true)]); - let new = mk_desc( - 16400, - vec![mk_attr(1, "id", 23, true), mk_attr(2, "name", 25, false)], - ); - let d = compute_schema_diff(&old, &new); - assert_eq!(d.added_columns.len(), 1); - assert_eq!(d.added_columns[0].attnum, 2); - assert!(d.dropped_columns.is_empty()); - assert!(d.renamed_columns.is_empty()); - assert!(d.type_changes.is_empty()); - } - - #[test] - fn schema_diff_detects_dropped_columns() { - let old = mk_desc( - 16400, - vec![mk_attr(1, "id", 23, true), mk_attr(2, "name", 25, false)], - ); - let new = mk_desc(16400, vec![mk_attr(1, "id", 23, true)]); - let d = compute_schema_diff(&old, &new); - assert_eq!(d.dropped_columns, vec![2]); - assert!(d.added_columns.is_empty()); - } - - #[test] - fn schema_diff_detects_rename_at_same_attnum() { - let old = mk_desc( - 16400, - vec![ - mk_attr(1, "id", 23, true), - mk_attr(2, "old_name", 25, false), - ], - ); - let new = mk_desc( - 16400, - vec![ - mk_attr(1, "id", 23, true), - mk_attr(2, "new_name", 25, false), - ], - ); - let d = compute_schema_diff(&old, &new); - assert_eq!( - d.renamed_columns, - vec![(2, "old_name".into(), "new_name".into())] - ); - assert!(d.added_columns.is_empty()); - assert!(d.dropped_columns.is_empty()); - assert!(d.type_changes.is_empty()); - } - - #[test] - fn schema_diff_detects_type_change_at_same_attnum() { - let old = mk_desc(16400, vec![mk_attr(1, "c", 23, true)]); // int4 - let new = mk_desc(16400, vec![mk_attr(1, "c", 20, true)]); // int8 - let d = compute_schema_diff(&old, &new); - assert_eq!(d.type_changes.len(), 1); - assert_eq!(d.type_changes[0].0, 1); - assert_eq!(d.type_changes[0].1.type_oid, 20); - } - - #[test] - fn schema_diff_skips_pg_dropped_columns_in_old() { - // PG retains DROP COLUMN as attisdropped=true in pg_attribute; diff must - // ignore them, not re-surface as still-present on the new side - let mut a = mk_attr(2, "x", 25, false); - a.dropped = true; - let old = mk_desc(16400, vec![mk_attr(1, "id", 23, true), a]); - let new = mk_desc(16400, vec![mk_attr(1, "id", 23, true)]); - let d = compute_schema_diff(&old, &new); - assert!(d.dropped_columns.is_empty()); - assert!(d.added_columns.is_empty()); - } - - #[test] - fn schema_diff_is_empty_when_shapes_match() { - let a = mk_desc( - 16400, - vec![mk_attr(1, "id", 23, true), mk_attr(2, "name", 25, false)], - ); - let b = a.clone(); - let d = compute_schema_diff(&a, &b); - assert!(d.is_empty()); - } - #[tokio::test(flavor = "current_thread")] async fn with_transient_retry_fails_fast_on_non_transient() { let calls = Arc::new(AtomicUsize::new(0)); diff --git a/src/config.rs b/src/config.rs index d0505595..714d19fa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -168,10 +168,6 @@ pub struct ConfigResolver { /// the applying xact route against the post-config mapping, not waiting on /// the async watch refresher. mapping: MappingHandle, - /// Decode-pool cache generation. Bumped inside a shape-changing apply and - /// on every inclusion add/remove so the decode pool re-resolves the - /// `rfn→mapping` entry it caches. - invalidation_epoch: Arc, /// Count of overlay values currently rejected at merge (Regime A). rejections: AtomicU64, /// Forward-declared opt-in rels awaiting their `CREATE TABLE` (gauge). @@ -193,7 +189,6 @@ impl ConfigResolver { toml_path: Option, cli_source_base: toml::Table, mapping: MappingHandle, - invalidation_epoch: Arc, ) -> (Arc, watch::Receiver>) { let overlay = ConfigOverlay::default(); let opt_in = OptInState::default(); @@ -210,7 +205,6 @@ impl ConfigResolver { }), tx, mapping, - invalidation_epoch, rejections: AtomicU64::new(0), pending_decl: AtomicU64::new(0), opt_in_total: AtomicU64::new(0), @@ -252,26 +246,20 @@ impl ConfigResolver { } /// Apply one WAL-driven config event at its commit LSN (§6). Mutates the - /// overlay, writes the routing map under the fence, bumps the cache - /// generation for shape-changing events, then republishes. Called from the - /// reorder coordinator's barrier apply, so it runs after earlier data in - /// the xact is durable and before the trailing segment dispatches. + /// overlay, writes the routing map under the fence, then republishes. + /// Called from the reorder coordinator's barrier apply, so it runs after + /// earlier data in the xact is durable and before the trailing segment + /// dispatches (decode memoises per job — nothing holds a stale mapping + /// across the fence). pub async fn apply_config_event(&self, event: ConfigEvent) { - let shape_change = matches!( - event, - ConfigEvent::ColumnUpserted { .. } | ConfigEvent::ColumnRemoved { .. } - ); let mut inner = self.inner.lock().await; inner.overlay.apply(event); - if shape_change { - self.invalidation_epoch.fetch_add(1, Ordering::Release); - } self.republish(&inner).await; } /// Bring `desc` into scope (`replicate=true`, rel known): derive a mapping /// from the descriptor, store it so it survives republish, drop any - /// pending / excluded state, bump the cache epoch, republish. Idempotent — + /// pending / excluded state, republish. Idempotent — /// a re-apply overwrites with an identical mapping. The caller must ensure /// the CH table exists first (see `DdlApplicator::ensure_ch_table`). pub async fn materialize_opt_in( @@ -297,15 +285,14 @@ impl ConfigResolver { self.pending_decl .store(inner.opt_in.pending_decl.len() as u64, Ordering::Relaxed); self.opt_in_total.fetch_add(1, Ordering::Relaxed); - self.invalidation_epoch.fetch_add(1, Ordering::Release); self.republish(&inner).await; } /// Take a rel out of scope (`replicate=false` / `TableRemoved`): drop its /// mapping + any pending decl, record the exclusion so republish keeps it - /// out even when TOML-mapped, bump the cache epoch, republish. In-flight - /// rows already dispatched still drain; further rows drop at - /// `lookup_mapping`. + /// out even when TOML-mapped, republish. In-flight + /// rows already dispatched still drain; later transactions plan + /// `route = None` discards. pub async fn exclude_table(&self, rel: &RelName) { let mut inner = self.inner.lock().await; inner.opt_in.mappings.remove(rel); @@ -315,7 +302,6 @@ impl ConfigResolver { self.pending_decl .store(inner.opt_in.pending_decl.len() as u64, Ordering::Relaxed); self.opt_out_total.fetch_add(1, Ordering::Relaxed); - self.invalidation_epoch.fetch_add(1, Ordering::Release); self.republish(&inner).await; } @@ -431,11 +417,15 @@ impl ConfigResolver { &prev.columns, ); self.rejections.store(rejections, Ordering::Relaxed); - *self.mapping.write().await = resolved.tables.clone(); - // Decode workers cache rfn→(descriptor, mapping) per epoch, cache + *self.mapping.write().await = Arc::new(resolved.tables.clone()); + tracing::info!( + target: "walshadow::config", + opt_ins = resolved.table_opt_ins.len(), + tables = resolved.tables.len(), + "republish", + ); // hits skip the mapping read; bump after every swap so no worker // routes against the pre-publish map - self.invalidation_epoch.fetch_add(1, Ordering::Release); // Err only when every receiver dropped (daemon tearing down); ignore let _ = self.tx.send(Arc::new(resolved)); } @@ -618,8 +608,8 @@ impl ConfigResolver { } // Opt-out (last, so exclusion wins over any TOML/overlay mapping): - // a `replicate=false` rel leaves the routing map, so `lookup_mapping` - // returns None and the decode pool drops its rows mid-stream. + // a `replicate=false` rel leaves the routing map, so route planning + // resolves None and its rows discard mid-stream. for rel in &opt_in.excluded { rc.tables.remove(rel); } @@ -664,11 +654,8 @@ mod tests { .unwrap() } - fn dummy_handles() -> (MappingHandle, Arc) { - ( - Arc::new(tokio::sync::RwLock::new(HashMap::new())), - Arc::new(AtomicU64::new(0)), - ) + fn dummy_handles() -> MappingHandle { + crate::mapping::mapping_handle(HashMap::new()) } #[test] @@ -914,6 +901,7 @@ mod tests { rel_node: 30000, }, oid: 30000, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new(namespace, name), kind: 'r', @@ -973,16 +961,15 @@ mod tests { } #[tokio::test] - async fn materialize_opt_in_derives_maps_and_bumps_epoch() { + async fn materialize_opt_in_derives_maps() { let base = base_with("retain"); - let (mapping, epoch) = dummy_handles(); + let mapping = dummy_handles(); let (resolver, mut rx) = ConfigResolver::new( &base, CliOverrides::default(), None, toml::Table::new(), mapping.clone(), - epoch.clone(), ); resolver .materialize_opt_in(&rel_desc("public", "events"), None, None) @@ -993,9 +980,8 @@ mod tests { let t = snap.tables.get(&rel).expect("mapping present"); assert_eq!(t.target.table, "events", "target derived from descriptor"); assert!(!t.columns.is_empty(), "columns derived from descriptor"); - // Fenced routing map written + cache epoch bumped for the decode pool. + // Fenced routing map written for the decode pool. assert!(mapping.read().await.contains_key(&rel)); - assert!(epoch.load(Ordering::Relaxed) >= 1); assert_eq!(resolver.opt_in_total(), 1); } @@ -1006,14 +992,13 @@ mod tests { columns = [{ attnum = 1, target = \"id\", type = \"Int32\" }]\n", ) .unwrap(); - let (mapping, epoch) = dummy_handles(); + let mapping = dummy_handles(); let (resolver, mut rx) = ConfigResolver::new( &base, CliOverrides::default(), None, toml::Table::new(), mapping.clone(), - epoch, ); let rel = RelName::new("public", "events"); assert!(rx.borrow().tables.contains_key(&rel)); @@ -1030,14 +1015,13 @@ mod tests { #[tokio::test] async fn derived_mapping_survives_republish() { let base = base_with("retain"); - let (mapping, epoch) = dummy_handles(); + let mapping = dummy_handles(); let (resolver, mut rx) = ConfigResolver::new( &base, CliOverrides::default(), None, toml::Table::new(), mapping.clone(), - epoch, ); let rel = RelName::new("public", "auto"); resolver @@ -1070,14 +1054,13 @@ mod tests { #[tokio::test] async fn forget_reparks_opt_in_row_as_pending_decl() { let base = base_with("drop"); - let (mapping, epoch) = dummy_handles(); + let mapping = dummy_handles(); let (resolver, _rx) = ConfigResolver::new( &base, CliOverrides::default(), None, toml::Table::new(), mapping.clone(), - epoch, ); let rel = RelName::new("public", "events"); resolver @@ -1112,14 +1095,13 @@ mod tests { columns = [{ attnum = 1, target = \"id\", type = \"Int32\" }]\n", ) .unwrap(); - let (mapping, epoch) = dummy_handles(); + let mapping = dummy_handles(); let (resolver, _rx) = ConfigResolver::new( &base, CliOverrides::default(), None, toml::Table::new(), mapping.clone(), - epoch, ); let mut desc = rel_desc("public", "events"); desc.attributes.push(RelAttr { @@ -1249,14 +1231,13 @@ mod tests { async fn column_override_survives_malformed_update_until_removed() { use crate::runtime_config::ColumnRow; let base = base_with("retain"); - let (mapping, epoch) = dummy_handles(); + let mapping = dummy_handles(); let (resolver, mut rx) = ConfigResolver::new( &base, CliOverrides::default(), None, toml::Table::new(), mapping, - epoch, ); let upsert = |ty: &str| ConfigEvent::ColumnUpserted { rel: RelName::new("public", "t"), @@ -1298,14 +1279,13 @@ mod tests { #[tokio::test] async fn pending_decl_parks_and_takes() { let base = base_with("retain"); - let (mapping, epoch) = dummy_handles(); + let mapping = dummy_handles(); let (resolver, _rx) = ConfigResolver::new( &base, CliOverrides::default(), None, toml::Table::new(), mapping, - epoch, ); let rel = RelName::new("app", "later"); resolver @@ -1320,14 +1300,13 @@ mod tests { #[tokio::test] async fn seed_and_apply_republish() { let base = base_with("retain"); - let (mapping, epoch) = dummy_handles(); + let mapping = dummy_handles(); let (resolver, mut rx) = ConfigResolver::new( &base, CliOverrides::default(), None, toml::Table::new(), mapping, - epoch, ); assert_eq!(rx.borrow().drop_table_strategy, "retain"); @@ -1352,14 +1331,13 @@ mod tests { #[tokio::test] async fn reload_without_path_is_noop() { let base = base_with("retain"); - let (mapping, epoch) = dummy_handles(); + let mapping = dummy_handles(); let (resolver, rx) = ConfigResolver::new( &base, CliOverrides::default(), None, toml::Table::new(), mapping, - epoch, ); resolver.reload().await.unwrap(); assert_eq!(rx.borrow().drop_table_strategy, "retain"); diff --git a/src/decode/codecs.rs b/src/decode/codecs.rs index 30b7dae3..f3fa531c 100644 --- a/src/decode/codecs.rs +++ b/src/decode/codecs.rs @@ -10,8 +10,7 @@ //! //! Each decoder takes the varlena *body* (or raw fixed-width bytes for //! `interval`) and produces a tagged value whose `text` matches PG -//! `typoutput`. Differential oracle ([`crate::ops::oracle`]) cross-checks that -//! text equality against shadow PG with 1-in-N sampling. +//! `typoutput`. use thiserror::Error; diff --git a/src/decode/decoder_sink.rs b/src/decode/decoder_sink.rs index 45d841d4..ccd1930c 100644 --- a/src/decode/decoder_sink.rs +++ b/src/decode/decoder_sink.rs @@ -97,6 +97,16 @@ crate::atomic_stats! { /// Records on marker-proven invisible filenodes stashed raw for /// commit-time resolution (same-xact CREATE / TRUNCATE / rewrite) pub toast_stash_buffered, + /// Records deferred raw because their xact tree wrote catalog + /// state earlier in the stream; resolve at commit under + /// post-boundary descriptors + pub raw_stash_deferred, + /// `raw_stash_records_total{kind="dirty",op}`: per-op split of + /// `raw_stash_deferred` + pub raw_stash_dirty_ops: crate::decode::heap_decoder::OpCounters, + /// `raw_stash_records_total{kind="marker",op}`: per-op split of + /// `toast_stash_buffered` + pub raw_stash_marker_ops: crate::decode::heap_decoder::OpCounters, } } diff --git a/src/decode/heap_decoder.rs b/src/decode/heap_decoder.rs index 596bb991..c5b2af4a 100644 --- a/src/decode/heap_decoder.rs +++ b/src/decode/heap_decoder.rs @@ -100,11 +100,61 @@ pub const XLOG_HEAP_DELETE: u8 = 0x10; pub const XLOG_HEAP_UPDATE: u8 = 0x20; pub const XLOG_HEAP_TRUNCATE: u8 = 0x30; pub const XLOG_HEAP_HOT_UPDATE: u8 = 0x40; +pub const XLOG_HEAP_CONFIRM: u8 = 0x50; pub const XLOG_HEAP_LOCK: u8 = 0x60; pub const XLOG_HEAP_INPLACE: u8 = 0x70; pub const XLOG_HEAP2_MULTI_INSERT: u8 = 0x50; +/// `op=` label vocabulary for raw stash/decode counters, index-aligned +/// with [`OpCounters`] +pub const HEAP_OP_LABELS: [&str; 7] = [ + "insert", + "delete", + "update", + "truncate", + "hot_update", + "multi_insert", + "other", +]; + +pub fn heap_op_index(rm: u8, info: u8) -> usize { + use walrus::pg::walparser::RmId; + let op = info & XLOG_HEAP_OPMASK; + if rm == RmId::Heap as u8 { + match op { + XLOG_HEAP_INSERT => 0, + XLOG_HEAP_DELETE => 1, + XLOG_HEAP_UPDATE => 2, + XLOG_HEAP_TRUNCATE => 3, + XLOG_HEAP_HOT_UPDATE => 4, + _ => 6, + } + } else if rm == RmId::Heap2 as u8 && op == XLOG_HEAP2_MULTI_INSERT { + 5 + } else { + 6 + } +} + +/// Per-op atomic counters, index-aligned with [`HEAP_OP_LABELS`] +#[derive(Debug, Default)] +pub struct OpCounters([std::sync::atomic::AtomicU64; 7]); + +impl OpCounters { + pub fn add(&self, rm: u8, info: u8, n: u64) { + self.0[heap_op_index(rm, info)].fetch_add(n, std::sync::atomic::Ordering::Relaxed); + } + + pub fn bump(&self, rm: u8, info: u8) { + self.add(rm, info, 1); + } + + pub fn load(&self) -> [u64; 7] { + std::array::from_fn(|i| self.0[i].load(std::sync::atomic::Ordering::Relaxed)) + } +} + // xl_heap_update.flags bit positions (heapam_xlog.h). pub const XLH_UPDATE_CONTAINS_OLD_TUPLE: u8 = 1 << 2; pub const XLH_UPDATE_CONTAINS_OLD_KEY: u8 = 1 << 3; @@ -192,10 +242,11 @@ 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 at emit via + /// on-disk body; resolved to text post-plan via /// `walshadow_decode_disk(oid, bytea) -> text` against shadow PG - /// (`walshadow` extension). Absent extension: emitter writes ``, - /// bumps `unsupported_values`. + /// (`walshadow` extension), best effort: shadow may lag row's catalog + /// state. Unresolved (extension absent / NULL result, counted + /// `fallback_raw`): emitter appends raw on-disk bytes. PgPending { type_oid: u32, raw: Vec, @@ -281,6 +332,30 @@ impl DecodedHeap { } } +/// Decoded heap frozen to the descriptor it decoded against. Downstream +/// (detoast, replica identity, routing, truncate apply) reads the attached +/// descriptor; no `descriptor_at` after construction, so a capture landing +/// between decode and drain can't reinterpret the row. Spill serializes +/// `descriptor_valid_from` instead of the `Arc`; rehydrate re-obtains the +/// descriptor by key and fails closed on span mismatch +#[derive(Debug, Clone, PartialEq)] +pub struct DescribedHeap { + pub decoded: DecodedHeap, + pub descriptor: std::sync::Arc, + /// Log entry `valid_from` of `descriptor` at attach + pub descriptor_valid_from: u64, +} + +impl DescribedHeap { + /// Resident accounting counts the retained `Arc` reference (contents + /// shared, not per-row) plus the span key + pub fn approx_bytes(&self) -> usize { + self.decoded.approx_bytes() + + std::mem::size_of::>() + + std::mem::size_of::() + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HeapOp { Insert, @@ -330,7 +405,7 @@ pub struct DecodedTuple { /// Unrecognised op codes skip silently; only malformed bytes return `Err`. /// /// `rel` must describe `record.blocks[0].header.location.rel`, fetched via -/// [`ShadowCatalog::relation_at`](crate::catalog::shadow_catalog::ShadowCatalog::relation_at). +/// [`DescriptorLog::descriptor_at`](crate::catalog::desc_log::DescriptorLog::descriptor_at). pub fn decode_heap_record( record: &XLogRecord, source_lsn: u64, @@ -1311,6 +1386,7 @@ mod tests { rel_node: rel, }, oid: 16384, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", "t"), kind: 'r', diff --git a/src/decode/wal_xact.rs b/src/decode/wal_xact.rs index f5c8ccf5..a2fb1b73 100644 --- a/src/decode/wal_xact.rs +++ b/src/decode/wal_xact.rs @@ -1,11 +1,14 @@ //! Transaction WAL record parsing +use crate::filter::catalog_tracker::PG_NAMESPACE_OID; + pub(crate) const XLOG_XACT_OPMASK: u8 = 0x70; pub(crate) const XLOG_XACT_COMMIT: u8 = 0x00; pub(crate) const XLOG_XACT_ABORT: u8 = 0x20; pub(crate) const XLOG_XACT_COMMIT_PREPARED: u8 = 0x30; pub(crate) const XLOG_XACT_ABORT_PREPARED: u8 = 0x40; pub(crate) const XLOG_XACT_ASSIGNMENT: u8 = 0x50; +pub(crate) const XLOG_XACT_INVALIDATIONS: u8 = 0x60; pub(crate) const XLOG_XACT_HAS_INFO: u8 = 0x80; pub(crate) const XACT_XINFO_HAS_DBINFO: u32 = 1 << 0; @@ -17,11 +20,77 @@ const XACT_XINFO_HAS_ORIGIN: u32 = 1 << 5; pub(crate) const XACT_XINFO_HAS_GID: u32 = 1 << 7; const XACT_XINFO_HAS_DROPPED_STATS: u32 = 1 << 8; +/// `SharedInvalRelcacheMsg`: relation whose relcache the committing xact +/// invalidated. `rel_id == 0` = whole-relcache flush. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RelcacheInval { + pub(crate) db_id: u32, + pub(crate) rel_id: u32, +} + +/// Classified `SharedInvalidationMessage` set. Relcache messages enumerate +/// affected rels; pg_namespace catcache / whole-catalog messages mark +/// namespace-text changes relcache invals never enumerate (capture-all +/// trigger) +#[derive(Debug, Default)] +pub(crate) struct InvalSet { + pub(crate) relcache: Vec, + /// db scope of pg_namespace syscache / whole-catalog invals + pub(crate) namespace: NamespaceInval, +} + +/// One backend writes one db, so scope is at most one oid plus db 0 +/// (shared-catalog messages target every db) +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NamespaceInval { + #[default] + Empty, + Shared, + Unshared(u32), + Both(u32), +} + +impl NamespaceInval { + fn mark(&mut self, db_id: u32) { + *self = match (*self, db_id) { + (Self::Empty | Self::Shared, 0) => Self::Shared, + (Self::Empty, db) => Self::Unshared(db), + (Self::Shared, db) | (Self::Unshared(db) | Self::Both(db), 0) => Self::Both(db), + (state @ (Self::Unshared(db) | Self::Both(db)), mark) if mark == db => state, + // second distinct db can't come from one backend; widen to all-db + _ => Self::Shared, + }; + } + + pub(crate) fn hits(self, local: impl Fn(u32) -> bool) -> bool { + match self { + Self::Empty => false, + Self::Shared => local(0), + Self::Unshared(db) => local(db), + Self::Both(db) => local(0) || local(db), + } + } +} + #[derive(Debug, Default)] pub(crate) struct XactCommitPayload { pub(crate) xact_time: i64, pub(crate) subxacts: Vec, pub(crate) twophase_xid: Option, + pub(crate) invals: InvalSet, +} + +/// Commit payload is descriptor-capture input: a silent partial parse could +/// drop the inval that marks a boundary, so malformation poisons the stream +/// instead +#[derive(Debug, thiserror::Error)] +#[error("xact payload: {0}")] +pub struct XactPayloadError(String); + +impl XactPayloadError { + pub(crate) fn new(what: impl Into) -> Self { + Self(what.into()) + } } pub(crate) fn parse_xact_assignment(mut data: &[u8]) -> Option<(u32, Vec)> { @@ -34,58 +103,126 @@ pub(crate) fn parse_xact_assignment(mut data: &[u8]) -> Option<(u32, Vec)> Some((top, subs)) } -pub(crate) fn parse_xact_payload(info: u8, mut data: &[u8]) -> XactCommitPayload { - let mut out = XactCommitPayload::default(); - let Some(time) = take_i64(&mut data) else { - return out; +/// `xl_xact_stats_item`: `(int kind, Oid dboid, Oid objoid)` = 12 bytes +/// through PG 17; PG 18 splits objid into two u32 = 16 bytes (PG +/// `src/include/access/xact.h`). Width keyed off the WAL page magic +/// (0xD118 = PG 18). +fn stats_item_width(page_magic: u16) -> usize { + if page_magic >= 0xD118 { 16 } else { 12 } +} + +/// pg_namespace syscache ids (`NAMESPACENAME`, `NAMESPACEOID`). +/// `SysCacheIdentifier` values shift across majors: name-sorted generation +/// (PG `src/backend/catalog/genbki.pl`; stable branches append via +/// Z-prefixed names so ids hold within a major). 35/36 on PG 16-17, +/// 37/38 on PG 18 (EXTENSIONNAME/OID sort ahead) +fn namespace_catcache_ids(page_magic: u16) -> [i8; 2] { + if page_magic >= 0xD118 { + [37, 38] + } else { + [35, 36] + } +} + +fn take_invals( + data: &mut &[u8], + page_magic: u16, + out: &mut InvalSet, +) -> Result<(), XactPayloadError> { + let err = |what: &str| XactPayloadError(what.to_string()); + let count = take_count(data).ok_or_else(|| err("inval count"))?; + let ns_ids = namespace_catcache_ids(page_magic); + for _ in 0..count { + // SharedInvalidationMessage: 16-byte union, id i8 at 0, dbId at 4; + // relcache relId / catalog catId at 8 (PG + // src/include/storage/sinval.h, layout identical on majors 16-18). + // Ids: >= 0 catcache (id = syscache id, payload is a hash — only + // "which catalog" is recoverable), -1 catalog, -2 relcache, -3 + // smgr, -4 relmap, -5 snapshot, -6 relsync (PG 18; skipping costs + // nothing on older majors where it cannot occur) + let msg: [u8; 16] = take(data).ok_or_else(|| err("inval msg"))?; + let db_id = u32::from_le_bytes(msg[4..8].try_into().unwrap()); + let arg = u32::from_le_bytes(msg[8..12].try_into().unwrap()); + match msg[0] as i8 { + -2 => out.relcache.push(RelcacheInval { db_id, rel_id: arg }), + -1 if arg == PG_NAMESPACE_OID => out.namespace.mark(db_id), + id if ns_ids.contains(&id) => out.namespace.mark(db_id), + -6..=-1 => {} + id if id >= 0 => {} + id => return Err(XactPayloadError(format!("unknown sinval id {id}"))), + } + } + Ok(()) +} + +/// `xl_xact_invals` (`XLOG_XACT_INVALIDATIONS`): command-boundary inval set +/// logged mid-xact at `wal_level=logical` (PG +/// `src/backend/utils/cache/inval.c` `LogLogicalInvalidations`). Lets the +/// filter re-dirty an open xact whose catalog writes precede the restart +/// resume floor +pub(crate) fn parse_xact_invalidations( + mut data: &[u8], + page_magic: u16, +) -> Result { + let mut out = InvalSet::default(); + take_invals(&mut data, page_magic, &mut out)?; + Ok(out) +} + +pub(crate) fn parse_xact_payload( + info: u8, + mut data: &[u8], + page_magic: u16, +) -> Result { + let err = |what: &str| XactPayloadError(what.to_string()); + let mut out = XactCommitPayload { + xact_time: take_i64(&mut data).ok_or_else(|| err("xact_time"))?, + ..Default::default() }; - out.xact_time = time; let xinfo = if info & XLOG_XACT_HAS_INFO != 0 { - let Some(value) = take_u32(&mut data) else { - return out; - }; - value + take_u32(&mut data).ok_or_else(|| err("xinfo"))? } else { 0 }; if xinfo & XACT_XINFO_HAS_DBINFO != 0 && !skip(&mut data, 8) { - return out; + return Err(err("dbinfo")); } if xinfo & XACT_XINFO_HAS_SUBXACTS != 0 { - let Some(count) = take_count(&mut data) else { - return out; - }; + let count = take_count(&mut data).ok_or_else(|| err("subxact count"))?; let mut subs = Vec::with_capacity(count); for _ in 0..count { - let Some(xid) = take_u32(&mut data) else { - return out; - }; - subs.push(xid); + subs.push(take_u32(&mut data).ok_or_else(|| err("subxact"))?); } out.subxacts = subs; } - if !skip_counted(&mut data, xinfo, XACT_XINFO_HAS_RELFILELOCATORS, 12) - || !skip_counted(&mut data, xinfo, XACT_XINFO_HAS_DROPPED_STATS, 16) - || !skip_counted(&mut data, xinfo, XACT_XINFO_HAS_INVALS, 16) - { - return out; + if !skip_counted(&mut data, xinfo, XACT_XINFO_HAS_RELFILELOCATORS, 12) { + return Err(err("relfilelocators")); + } + if !skip_counted( + &mut data, + xinfo, + XACT_XINFO_HAS_DROPPED_STATS, + stats_item_width(page_magic), + ) { + return Err(err("dropped stats")); + } + if xinfo & XACT_XINFO_HAS_INVALS != 0 { + take_invals(&mut data, page_magic, &mut out.invals)?; } if xinfo & XACT_XINFO_HAS_TWOPHASE != 0 { - let Some(xid) = take_u32(&mut data) else { - return out; - }; - out.twophase_xid = Some(xid); + out.twophase_xid = Some(take_u32(&mut data).ok_or_else(|| err("twophase xid"))?); if xinfo & XACT_XINFO_HAS_GID != 0 { - let Some(end) = data.iter().position(|byte| *byte == 0) else { - return out; - }; + let end = data + .iter() + .position(|byte| *byte == 0) + .ok_or_else(|| err("gid terminator"))?; data = &data[end + 1..]; } } if xinfo & XACT_XINFO_HAS_ORIGIN != 0 && data.len() < 16 { - return out; + return Err(err("origin")); } - out + Ok(out) } fn take(data: &mut &[u8]) -> Option<[u8; N]> { diff --git a/src/emit/ch_ddl.rs b/src/emit/ch_ddl.rs index 265983cb..59fee310 100644 --- a/src/emit/ch_ddl.rs +++ b/src/emit/ch_ddl.rs @@ -22,7 +22,6 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use clickhouse_c::AsyncClient; @@ -131,7 +130,6 @@ pub struct DdlApplicator { /// barrier mutates the mapping here. Bump again on every mapping write /// so a worker whose refresh consumed the record-time bump drops its /// pre-apply snapshot - invalidation_epoch: Option>, /// Owner of runtime-derived mapping state. Set: auto-created mappings, /// diff folds, and DROP forgets record into the resolver so the /// republish full-swap preserves them. Unset (bootstrap drain, tests @@ -170,20 +168,12 @@ impl DdlApplicator { retry: emitter_cfg.retry.clone(), query_timeout: emitter_cfg.insert_timeout, last_used: std::time::Instant::now(), - invalidation_epoch: None, resolver: None, ensured_databases: HashSet::new(), stats: DdlStats::default(), }) } - /// Same handle as `CatalogTracker::set_invalidation_epoch`. Unset skips - /// bumps (bootstrap drain, tests without a decode pool) - pub fn with_invalidation_epoch(mut self, epoch: Arc) -> Self { - self.invalidation_epoch = Some(epoch); - self - } - /// Route mapping writes through the resolver so they survive its /// republish full-swap (the [config.md] "Known limitation" clobber). pub fn with_resolver(mut self, resolver: Arc) -> Self { @@ -493,15 +483,70 @@ impl DdlApplicator { } /// Mapping writes route per `resolver` field: resolver-owned entries - /// survive the republish full-swap (republish writes the live handle + - /// bumps the epoch); resolver-less path mutates the live handle and - /// bumps directly + /// survive the republish full-swap; resolver-less path mutates the live + /// handle directly. Decode workers memoise per job and mapping writes + /// land inside the barrier fence, between jobs — no epoch needed async fn register_mapping(&mut self, rel: &RelName, mapping: TableMapping) { if let Some(r) = &self.resolver { r.register_derived_mapping(rel, mapping).await; } else { - self.mapping.write().await.insert(rel.clone(), mapping); - bump_mapping_epoch(self.invalidation_epoch.as_ref()); + Arc::make_mut(&mut *self.mapping.write().await).insert(rel.clone(), mapping); + } + } + + /// Predict `apply(event)`'s routing-map effect without executing DDL or + /// touching shared state, for plan-time route resolution: `None` = map + /// unchanged, `Some((rel, Some(m)))` = insert/replace, `Some((rel, + /// None))` = remove. Mirrors the apply_added / apply_changed / + /// apply_dropped gates; the executor later applies the real effects + pub async fn predict_route_mapping( + &mut self, + event: &SchemaEvent, + ) -> Result)>, EmitterError> { + self.refresh_config().await?; + match event { + SchemaEvent::Added { desc } => { + if self.mapping_for(&desc.rel_name).await.is_some() + || self.is_excluded(&desc.rel_name).await + || !self + .config + .auto_create_namespaces + .contains(&*desc.rel_name.namespace) + { + return Ok(None); + } + let target_db = self + .config + .target_database_for(&desc.rel_name.namespace) + .to_owned(); + if render_create_table(desc, &target_db, self.config.soft_delete)?.is_none() { + return Ok(None); + } + let target = TableTarget::new(&target_db, &desc.rel_name.name); + let columns = derive_columns_for_mapping(desc); + Ok(Some(( + desc.rel_name.clone(), + Some(TableMapping { target, columns }), + ))) + } + SchemaEvent::Changed { new, diff, .. } => { + let Some(mut m) = self.mapping_for(&new.rel_name).await else { + return Ok(None); + }; + fold_diff_into_mapping(&mut m, new, diff); + Ok(Some((new.rel_name.clone(), Some(m)))) + } + SchemaEvent::Dropped { rel_name, .. } => { + if self.mapping_target(rel_name).await.is_none() + || !matches!( + self.config.drop_strategy_for(&rel_name.namespace), + DropTableStrategy::Drop + ) + { + return Ok(None); + } + Ok(Some((rel_name.clone(), None))) + } } } @@ -509,8 +554,7 @@ impl DdlApplicator { if let Some(r) = &self.resolver { r.apply_schema_diff(new, diff).await; } else { - mutate_mapping_for_diff(&self.mapping, self.invalidation_epoch.as_ref(), new, diff) - .await; + mutate_mapping_for_diff(&self.mapping, new, diff).await; } } @@ -518,8 +562,7 @@ impl DdlApplicator { if let Some(r) = &self.resolver { r.forget_derived_mapping(rel).await; } else { - self.mapping.write().await.remove(rel); - bump_mapping_epoch(self.invalidation_epoch.as_ref()); + Arc::make_mut(&mut *self.mapping.write().await).remove(rel); } } @@ -567,35 +610,16 @@ impl DdlApplicator { } } -/// Flush decode-pool `RelCache` mapping snapshots after a mapping write. -/// Call only after the write guard drops: bump-before-write would let a -/// racing `RelCache::refresh` cache the pre-write map under the post-bump -/// epoch, going permanently stale -pub fn bump_mapping_epoch(epoch: Option<&Arc>) { - if let Some(e) = epoch { - e.fetch_add(1, Ordering::Release); - } -} - -/// Fold a `Changed` diff into the live mapping, then bump the epoch so -/// decode-pool workers holding a pre-diff snapshot re-resolve. Renames touch -/// only entries whose `target_name` still equals the OLD source name; an -/// operator-pinned different name is left alone (CH runs no ALTER for it -/// either, see `apply_changed`) -async fn mutate_mapping_for_diff( - mapping: &MappingHandle, - epoch: Option<&Arc>, - new: &RelDescriptor, - diff: &SchemaDiff, -) { - { - let mut m = mapping.write().await; - let Some(target_mapping) = m.get_mut(&new.rel_name) else { - return; - }; - fold_diff_into_mapping(target_mapping, new, diff); - } - bump_mapping_epoch(epoch); +/// Fold a `Changed` diff into the live mapping. Renames touch only entries +/// whose `target_name` still equals the OLD source name; an operator-pinned +/// different name is left alone (CH runs no ALTER for it either, see +/// `apply_changed`) +async fn mutate_mapping_for_diff(mapping: &MappingHandle, new: &RelDescriptor, diff: &SchemaDiff) { + let mut m = mapping.write().await; + let Some(target_mapping) = Arc::make_mut(&mut *m).get_mut(&new.rel_name) else { + return; + }; + fold_diff_into_mapping(target_mapping, new, diff); } /// The fold itself, shared with `ConfigResolver::apply_schema_diff` (the @@ -724,7 +748,6 @@ mod tests { use crate::mapping::{ColumnMapping, TableMapping}; use crate::schema::{INT4OID, TEXTOID, TIMESTAMPTZOID}; use crate::schema::{RelAttr, RelDescriptor, ReplIdent, SchemaDiff}; - use std::sync::Arc; #[test] fn per_namespace_target_and_drop_override_global() { @@ -810,6 +833,7 @@ mod tests { rel_node: 16400, }, oid: 16400, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", name), kind: 'r', @@ -1087,7 +1111,7 @@ mod tests { )] .into_iter() .collect(); - let handle: MappingHandle = Arc::new(tokio::sync::RwLock::new(map)); + let handle = crate::mapping::mapping_handle(map); let rt = tokio::runtime::Builder::new_current_thread() .build() .unwrap(); @@ -1114,8 +1138,7 @@ mod tests { )] .into_iter() .collect(); - let handle: MappingHandle = Arc::new(tokio::sync::RwLock::new(map)); - let epoch = Arc::new(AtomicU64::new(7)); + let handle = crate::mapping::mapping_handle(map); let new = desc( "orders", vec![ @@ -1134,9 +1157,8 @@ mod tests { .build() .unwrap(); rt.block_on(async { - // Mapped relation: column folded in, epoch bumped so decode-pool - // RelCaches drop pre-diff mapping snapshots - mutate_mapping_for_diff(&handle, Some(&epoch), &new, &diff).await; + // Mapped relation: column folded in + mutate_mapping_for_diff(&handle, &new, &diff).await; let m = handle.read().await; let cols = &m.get(&RelName::new("public", "orders")).unwrap().columns; assert!( @@ -1144,16 +1166,9 @@ mod tests { .any(|c| c.src_attnum == 2 && c.target_name == "c") ); }); - assert_eq!(epoch.load(Ordering::Acquire), 8); - // Unmapped relation: early return, no spurious bump + // Unmapped relation: early return let ghost = desc("ghost", vec![att(1, "id", INT4OID, true, None)], None); - rt.block_on(mutate_mapping_for_diff( - &handle, - Some(&epoch), - &ghost, - &diff, - )); - assert_eq!(epoch.load(Ordering::Acquire), 8); + rt.block_on(mutate_mapping_for_diff(&handle, &ghost, &diff)); } } diff --git a/src/emit/ch_emitter.rs b/src/emit/ch_emitter.rs index 934f5dd3..34e1baff 100644 --- a/src/emit/ch_emitter.rs +++ b/src/emit/ch_emitter.rs @@ -63,6 +63,7 @@ pub(crate) const DEFAULT_BYTE_BUDGET: usize = 1 << 20; // 1 MiB /// it stay within the ingest budget. pub(crate) const DEFAULT_DRAIN_BATCH_ROWS: usize = 65_536; pub(crate) const DEFAULT_DRAIN_BATCH_BYTES: usize = 32 << 20; // 32 MiB +pub(crate) const DEFAULT_PLAN_DISK_MAX: u64 = 8 << 30; // 8 GiB /// Default flush timeout (ms). `0` keeps serial emitter's /// close-INSERT-on-every-xact-end behaviour (bootstrap backfill only); @@ -148,6 +149,9 @@ pub struct EmitterConfig { /// generations stay per-xact. pub drain_batch_rows: usize, pub drain_batch_bytes: usize, + /// `[clickhouse] plan_disk_max`: byte cap per transaction plan spool + /// file; a larger transaction fails planning instead of filling disk + pub plan_disk_max: u64, /// `[runtime_config] schema`: source-PG schema housing the `config_*` /// overlay tables. `None` (field empty or omitted) disables the whole /// overlay subsystem — no boot seed, no config_decoder, pure TOML+CLI. @@ -223,6 +227,7 @@ impl Default for EmitterConfig { decode_chunk_rows: DEFAULT_DECODE_CHUNK_ROWS, drain_batch_rows: DEFAULT_DRAIN_BATCH_ROWS, drain_batch_bytes: DEFAULT_DRAIN_BATCH_BYTES, + plan_disk_max: DEFAULT_PLAN_DISK_MAX, runtime_config_schema: None, source_slot: None, resident_payload_max: DEFAULT_RESIDENT_PAYLOAD_MAX, @@ -465,6 +470,9 @@ impl EmitterConfig { if let Some(v) = ch.get("drain_batch_bytes").and_then(Value::as_integer) { out.drain_batch_bytes = usize::try_from(v).unwrap_or(DEFAULT_DRAIN_BATCH_BYTES); } + if let Some(v) = ch.get("plan_disk_max").and_then(Value::as_integer) { + out.plan_disk_max = u64::try_from(v).unwrap_or(DEFAULT_PLAN_DISK_MAX); + } if let Some(v) = ch.get("flush_timeout_ms").and_then(Value::as_integer) && let Ok(ms) = u64::try_from(v) { @@ -1489,9 +1497,9 @@ fn encode_value( target_column: String::new(), kind: "unresolved TOAST pointer (xact buffer should have reassembled)", }), - // PgPending normally resolves to text earlier (BufferingDecoderSink - // via the oracle extension). Still set here means extension absent; - // fall back to raw on-disk bytes so CH still gets the value + // 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 ColumnValue::PgPending { raw, .. } => buf.append_string_bytes(raw), ColumnValue::Unsupported { .. } => Err(EmitterError::UnsupportedValue { target_column: String::new(), @@ -1508,9 +1516,6 @@ crate::atomic_stats! { pub blocks_sent, pub xacts_committed, pub unsupported_relations, - /// Rows whose filenode resolved to a foreign database (physical - /// WAL carries the whole cluster). Skipped, not an error. - pub foreign_db_rows_skipped, pub unsupported_values, /// `retries_attempted` counts one per failing operation, not per /// attempt (one op needing 3 retries adds 3) @@ -1544,9 +1549,39 @@ crate::atomic_stats! { /// Stashed records discarded: filenode unresolvable post-commit /// (dropped or rotated away), end-state-neutral by AEL supersession pub toast_stash_discarded, - /// Stashed records resolved to a non-toast heap; ordinary-heap decode - /// stays fenced off until a shadow replay fence exists - pub toast_stash_skipped, + /// Stashed filenodes resolved to a foreign database at commit, + /// cluster-level skip counted once per filenode + pub stash_foreign_db_skipped, + /// Routed heaps sealed into transaction plans (unmapped discards + /// excluded) + pub plan_rows, + /// Sealed plan bytes by final backing + pub plan_bytes_mem, + pub plan_bytes_file, + /// Planning-stage failures by reason; a failed plan means the whole + /// transaction emits nothing + pub plan_failures_spool, + pub plan_failures_fail_closed_image_only, + pub plan_failures_fail_closed_malformed, + pub plan_failures_fail_closed_unsupported_op, + pub plan_failures_stash_ambiguous, + pub plan_failures_incomplete_toast, + pub plan_failures_missing_stash_resolution, + pub plan_failures_detoast, + pub plan_failures_partial_update, + pub plan_failures_view, + pub plan_failures_drain, + /// Plan-time route resolutions, one per relation per transaction + /// (memoised) + pub route_snapshots_mapped, + pub route_snapshots_unmapped, + /// Commit-resolve raw decode: records by verdict kind, per op + /// (`raw_decode_records_total{kind,op}`) + pub raw_decode_toast_ops: crate::decode::heap_decoder::OpCounters, + pub raw_decode_ordinary_ops: crate::decode::heap_decoder::OpCounters, + /// Rows fanned out of decoded raw records per op + /// (`raw_decode_rows_total{op}`); MULTI_INSERT yields many per record + pub raw_decode_rows_ops: crate::decode::heap_decoder::OpCounters, // Pipeline-flow counters; `_out`/`_in` pairs give channel depth. See // `metrics::render`. pub queue_jobs_out, @@ -1730,6 +1765,7 @@ mod tests { rel_node: 16385, }, oid: 16385, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", "foo"), kind: 'r', diff --git a/src/emit/mod.rs b/src/emit/mod.rs index 63cb584a..de952a3d 100644 --- a/src/emit/mod.rs +++ b/src/emit/mod.rs @@ -1,3 +1,4 @@ pub mod ch_ddl; pub mod ch_emitter; pub mod pipeline; +pub mod route; diff --git a/src/emit/pipeline/batcher.rs b/src/emit/pipeline/batcher.rs index 8f233c9c..5d9eadf2 100644 --- a/src/emit/pipeline/batcher.rs +++ b/src/emit/pipeline/batcher.rs @@ -30,15 +30,15 @@ use crate::emit::ch_emitter::{ ColumnBuf, EmitterStats, OP_DELETE, OP_INSERT, OP_UPDATE, TableEncoder, TablePlan, }; use crate::emit::pipeline::{DEFAULT_PIPELINE_FLUSH, Fatal}; -use crate::mapping::TableMapping; +use crate::emit::route::RouteSnapshot; use crate::schema::{RelDescriptor, RelName}; -/// One decoded row routed to its destination. `mapping`/`rel` are `Arc` +/// One decoded row routed to its destination. `route`/`rel` are `Arc` /// clones a decoder resolves once per xact/table. pub struct RoutedRow { pub seq: u64, pub rel: Arc, - pub mapping: Arc, + pub route: Arc, pub committed: CommittedTuple, /// Admission permit share riding the row into batcher slabs and the /// in-flight insert block; released post-insert-ack when the covering @@ -150,9 +150,6 @@ struct RowCtx<'a> { out: &'a async_channel::Sender, alloc: Allocator, epoch: u64, - /// Live snapshot for `config_column` overrides at plan build; `None` - /// when the overlay is off. - resolved: Option<&'a ResolvedConfig>, stats: &'a EmitterStats, } @@ -170,8 +167,7 @@ pub(crate) fn spawn( tokio::spawn(async move { let mut tables: HashMap = HashMap::new(); let mut epoch: u64 = 0; - let mut snap = snapshot(config_rx.as_ref()); - let mut live = effective_cfg(&cfg, snap.as_deref()); + let mut live = effective_cfg(&cfg, snapshot(config_rx.as_ref()).as_deref()); let mut ticker = tokio::time::interval(live.flush_timeout); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); let stats = stats.as_ref(); @@ -179,18 +175,16 @@ pub(crate) fn spawn( tokio::select! { msg = msg_rx.recv() => match msg { Some(BatcherMsg::Row(r)) => { - snap = snapshot(config_rx.as_ref()); - live = effective_cfg(&cfg, snap.as_deref()); - let ctx = RowCtx { cfg: live, out: &out, alloc, epoch, resolved: snap.as_deref(), stats }; + live = effective_cfg(&cfg, snapshot(config_rx.as_ref()).as_deref()); + let ctx = RowCtx { cfg: live, out: &out, alloc, epoch, stats }; if let Err(e) = handle_row(&mut tables, &ctx, r).await { fatal.set(format!("batcher: {e}")); break; } } Some(BatcherMsg::Rows(rows)) => { - snap = snapshot(config_rx.as_ref()); - live = effective_cfg(&cfg, snap.as_deref()); - let ctx = RowCtx { cfg: live, out: &out, alloc, epoch, resolved: snap.as_deref(), stats }; + live = effective_cfg(&cfg, snapshot(config_rx.as_ref()).as_deref()); + let ctx = RowCtx { cfg: live, out: &out, alloc, epoch, stats }; if let Err(e) = handle_rows(&mut tables, &ctx, rows).await { fatal.set(format!("batcher: {e}")); break; @@ -220,8 +214,7 @@ pub(crate) fn spawn( // Live emitter-knob change: re-arm the deadline ticker to the // new flush_timeout. Budgets are re-read per message below. _ = config_changed(&mut config_rx) => { - snap = snapshot(config_rx.as_ref()); - live = effective_cfg(&cfg, snap.as_deref()); + live = effective_cfg(&cfg, snapshot(config_rx.as_ref()).as_deref()); ticker = tokio::time::interval(live.flush_timeout); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); } @@ -231,8 +224,7 @@ pub(crate) fn spawn( } /// Latest resolved snapshot off the watch, `None` when the overlay is off. -/// One `Arc` clone per message; also feeds the per-table column overrides at -/// plan build. +/// Feeds live batch knobs only; column overrides ride the route snapshot. fn snapshot(rx: Option<&watch::Receiver>>) -> Option> { rx.map(|rx| rx.borrow().clone()) } @@ -289,12 +281,16 @@ async fn handle_row( .fetch_add(1, Ordering::Relaxed); let key = &row.rel.rel_name; if !tables.contains_key(key) { - // Column-type overrides re-read per plan build; a `Column*` config + // Overrides ride the route frozen at planning; a `Column*` config // event applies under the barrier fence, whose FlushAll cleared this - // plan cache, so post-apply rows rebuild against the new snapshot - let overrides = ctx.resolved.and_then(|rc| rc.columns.get(key)); - let plan = TablePlan::build(ctx.alloc, &row.rel, &row.mapping, overrides) - .map_err(|e| e.to_string())?; + // plan cache, so post-apply rows rebuild from post-apply routes + let plan = TablePlan::build( + ctx.alloc, + &row.rel, + &row.route.mapping, + Some(&row.route.column_overrides), + ) + .map_err(|e| e.to_string())?; let meta = Arc::new(BatchMeta::from_plan(&plan, key.clone(), ctx.epoch)); let enc = TableEncoder::new(plan).map_err(|e| e.to_string())?; tables.insert( @@ -326,7 +322,7 @@ async fn handle_row( HeapOp::Truncate => return Err("TRUNCATE routed to batcher".into()), }; t.enc - .append_row(&row.committed, &row.mapping, op) + .append_row(&row.committed, &row.route.mapping, op) .map_err(|e| e.to_string())?; match t.seq_counts.last_mut() { Some((s, c)) if *s == row.seq => *c += 1, @@ -413,7 +409,7 @@ async fn flush_all( mod tests { use super::*; use crate::decode::heap_decoder::{ColumnValue, DecodedHeap, DecodedTuple, HeapOp}; - use crate::mapping::{ColumnMapping, TableTarget}; + use crate::mapping::{ColumnMapping, TableMapping, TableTarget}; use crate::schema::{RelAttr, RelDescriptor, RelName, ReplIdent}; use tokio::sync::oneshot; use walrus::pg::walparser::RelFileNode; @@ -426,6 +422,7 @@ mod tests { rel_node: 16385, }, oid: 16385, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", "t"), kind: 'r', @@ -448,22 +445,26 @@ mod tests { }) } - fn mapping() -> Arc { - Arc::new(TableMapping { - target: TableTarget::new("default", "t"), - columns: vec![ColumnMapping { - src_attnum: 1, - target_name: "id".into(), - target_type: "Int32".into(), - }], - }) + fn route() -> Arc { + RouteSnapshot::freeze( + Arc::new(TableMapping { + target: TableTarget::new("default", "t"), + columns: vec![ColumnMapping { + src_attnum: 1, + target_name: "id".into(), + target_type: "Int32".into(), + }], + }), + Arc::default(), + false, + ) } fn row(seq: u64, id: i32) -> RoutedRow { RoutedRow { seq, rel: rel(), - mapping: mapping(), + route: route(), committed: CommittedTuple { decoded: DecodedHeap { rfn: RelFileNode { @@ -587,6 +588,54 @@ mod tests { assert!(fatal.message().is_none(), "no fatal: {:?}", fatal.message()); } + /// Route-carried `config_column` override reaches the encoder plan + /// unchanged with no config watch wired — the snapshot is the only + /// override source (spec §Tests / Route and config) + #[tokio::test] + async fn route_override_snapshot_reaches_plan() { + let (msg_tx, msg_rx) = mpsc::channel(8); + let (batches_tx, batches_rx) = async_channel::bounded(8); + let fatal = Fatal::new(); + let handle = spawn( + msg_rx, + batches_tx, + BatcherConfig { + row_budget: 1, + byte_budget: 1 << 30, + flush_timeout: Duration::from_secs(3600), + }, + Allocator::stdlib(), + fatal.clone(), + Arc::new(EmitterStats::default()), + None, + ); + // Int32 → UInt32 is wire-compatible (same fixed width), admissible + let overrides = HashMap::from([(String::from("id"), String::from("UInt32"))]); + let mut r = row(0, 1); + r.route = RouteSnapshot::freeze( + Arc::new(TableMapping { + target: TableTarget::new("default", "t"), + columns: vec![ColumnMapping { + src_attnum: 1, + target_name: "id".into(), + target_type: "Int32".into(), + }], + }), + Arc::new(overrides), + false, + ); + msg_tx.send(BatcherMsg::Row(r)).await.expect("send row"); + drop(msg_tx); + let batch = batches_rx.recv().await.expect("one batch"); + assert_eq!(batch.meta.columns[0].name, "id"); + assert_eq!( + batch.meta.columns[0].type_repr, "UInt32", + "override rode the route snapshot into the plan" + ); + handle.await.expect("batcher task"); + assert!(fatal.message().is_none()); + } + /// FlushAll seals everything sent before it and replies, even below budget /// with a huge deadline (the barrier's drain-before-DDL step). Shared FIFO /// channel means the first-enqueued row can't be missed (bug `codex.md`). diff --git a/src/emit/pipeline/bootstrap.rs b/src/emit/pipeline/bootstrap.rs index e98fe7f1..3f36cd2b 100644 --- a/src/emit/pipeline/bootstrap.rs +++ b/src/emit/pipeline/bootstrap.rs @@ -12,10 +12,12 @@ use tokio::sync::mpsc; use crate::backfill::backup_page_walk::{BackfillTuple, CatalogMap}; use crate::backfill::spool::DeferredSpool; +use crate::config::ResolvedConfig; use crate::decode::heap_decoder::{ColumnValue, ToastPointer}; use crate::emit::ch_emitter::EmitterStats; use crate::emit::pipeline::ack::AckHandle; use crate::emit::pipeline::batcher::{BatcherMsg, RoutedRow}; +use crate::emit::route::RouteSnapshot; use crate::mapping::{MappingHandle, TableMapping}; use crate::schema::RelDescriptor; use crate::toast::{ @@ -44,12 +46,26 @@ pub async fn drain( stats: Arc, resolver: ToastResolver, mut deferred: DeferredSpool, + soft_delete: bool, + config: Option>, ) -> Result { - let mappings: HashMap<_, _> = mapping_handle + // Routes frozen once per pass from the caller's config snapshot + let routes: HashMap<_, _> = mapping_handle .read() .await .iter() - .map(|(name, mapping)| (name.clone(), Arc::new(mapping.clone()))) + .map(|(name, mapping)| { + let overrides = config + .as_ref() + .and_then(|rc| rc.columns.get(name)) + .cloned() + .map(Arc::new) + .unwrap_or_default(); + ( + name.clone(), + RouteSnapshot::freeze(Arc::new(mapping.clone()), overrides, soft_delete), + ) + }) .collect(); let mut next_seq = 0u64; let mut rows_routed = 0u64; @@ -93,12 +109,12 @@ pub async fn drain( continue; } - let Some(mapping) = mappings.get(&rel.rel_name).cloned() else { + let Some(route) = routes.get(&rel.rel_name).cloned() else { stats.unsupported_relations.fetch_add(1, Ordering::Relaxed); continue; }; - if has_mapped_external_toast(&tuple, &mapping) { + if has_mapped_external_toast(&tuple, &route.mapping) { if resolver.stores_chunks() { deferred .push(tuple) @@ -113,13 +129,13 @@ pub async fn drain( continue; } let mut tuple = tuple; - let permit = resolve_or_fill_toast(&mut tuple, &rel, &mapping, &resolver).await?; - route_row(&msg_tx, seq, rel, mapping, tuple, permit).await?; + let permit = resolve_or_fill_toast(&mut tuple, &rel, &route.mapping, &resolver).await?; + route_row(&msg_tx, seq, rel, route, tuple, permit).await?; bump(&mut open, &mut rows_routed); continue; } - route_row(&msg_tx, seq, rel, mapping, tuple, None).await?; + route_row(&msg_tx, seq, rel, route, tuple, None).await?; bump(&mut open, &mut rows_routed); } @@ -155,12 +171,12 @@ pub async fn drain( stats.unsupported_relations.fetch_add(1, Ordering::Relaxed); continue; }; - let Some(mapping) = mappings.get(&rel.rel_name).cloned() else { + let Some(route) = routes.get(&rel.rel_name).cloned() else { stats.unsupported_relations.fetch_add(1, Ordering::Relaxed); continue; }; - let permit = resolve_or_fill_toast(&mut tuple, &rel, &mapping, &resolver).await?; - route_row(&msg_tx, seq, rel, mapping, tuple, permit).await?; + let permit = resolve_or_fill_toast(&mut tuple, &rel, &route.mapping, &resolver).await?; + route_row(&msg_tx, seq, rel, route, tuple, permit).await?; placed += 1; rows_routed += 1; } @@ -192,7 +208,7 @@ async fn route_row( msg_tx: &mpsc::Sender, seq: u64, rel: Arc, - mapping: Arc, + route: Arc, tuple: BackfillTuple, value_permit: Option, ) -> Result<(), String> { @@ -201,7 +217,7 @@ async fn route_row( .send(BatcherMsg::Row(RoutedRow { seq, rel, - mapping, + route, committed, permit: None, value_permit: value_permit.map(Arc::new), @@ -354,6 +370,7 @@ mod tests { rel_node, }, oid: rel_node, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", &name), kind: 'r', @@ -414,6 +431,7 @@ mod tests { rel_node, }, oid: rel_node, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", &name), kind: 'r', @@ -459,6 +477,7 @@ mod tests { rel_node, }, oid: rel_node, + toast_oid: 0, namespace_oid: 99, rel_name: RelName::new("pg_toast", &name), kind: 't', @@ -526,7 +545,7 @@ mod tests { let mut tables = HashMap::new(); tables.insert(RelName::new("public", "t16400"), mapping_for(16400)); tables.insert(RelName::new("public", "t16401"), mapping_for(16401)); - let mapping: MappingHandle = Arc::new(tokio::sync::RwLock::new(tables)); + let mapping = crate::mapping::mapping_handle(tables); let emitter_ack = Arc::new(AtomicU64::new(0)); let (ack, collector) = ack::spawn(emitter_ack); @@ -551,6 +570,8 @@ mod tests { stats.clone(), ToastResolver::disabled(), mem_spool(), + false, + None, )); let mut by_seq: HashMap = HashMap::new(); @@ -578,7 +599,7 @@ mod tests { catalog.insert(rel(16401)); // resolvable but unmapped let mut tables = HashMap::new(); tables.insert(RelName::new("public", "t16400"), mapping_for(16400)); - let mapping: MappingHandle = Arc::new(tokio::sync::RwLock::new(tables)); + let mapping = crate::mapping::mapping_handle(tables); let emitter_ack = Arc::new(AtomicU64::new(0)); let (ack, collector) = ack::spawn(emitter_ack); @@ -601,6 +622,8 @@ mod tests { stats.clone(), ToastResolver::disabled(), mem_spool(), + false, + None, )); let mut seqs: Vec = Vec::new(); @@ -624,7 +647,7 @@ mod tests { catalog.insert(bytea_rel(16400)); let mut tables = HashMap::new(); tables.insert(RelName::new("public", "t16400"), bytea_mapping_for(16400)); - let mapping: MappingHandle = Arc::new(tokio::sync::RwLock::new(tables)); + let mapping = crate::mapping::mapping_handle(tables); let emitter_ack = Arc::new(AtomicU64::new(0)); let (ack, collector) = ack::spawn(emitter_ack); @@ -649,6 +672,8 @@ mod tests { stats.clone(), resolver, mem_spool(), + false, + None, )); let mut rows = Vec::new(); @@ -681,7 +706,7 @@ mod tests { catalog.insert(toast_rel(16500)); let mut tables = HashMap::new(); tables.insert(RelName::new("public", "t16400"), bytea_mapping_for(16400)); - let mapping: MappingHandle = Arc::new(tokio::sync::RwLock::new(tables)); + let mapping = crate::mapping::mapping_handle(tables); let emitter_ack = Arc::new(AtomicU64::new(0)); let (ack, collector) = ack::spawn(emitter_ack); @@ -712,6 +737,8 @@ mod tests { ToastResolver::with_store(store, stats.clone()), // Threshold 0: deferred referrer rides a real spool file DeferredSpool::new(spool_tmp.path().join("bootstrap_deferred.bin"), 0), + false, + None, )); let mut rows = Vec::new(); @@ -741,7 +768,7 @@ mod tests { catalog.insert(toast_rel(16500)); let mut tables = HashMap::new(); tables.insert(RelName::new("public", "t16400"), bytea_mapping_for(16400)); - let mapping: MappingHandle = Arc::new(tokio::sync::RwLock::new(tables)); + let mapping = crate::mapping::mapping_handle(tables); let emitter_ack = Arc::new(AtomicU64::new(0)); let (ack, collector) = ack::spawn(emitter_ack); @@ -768,12 +795,14 @@ mod tests { stats.clone(), ToastResolver::with_store(store, stats.clone()), mem_spool(), + false, + None, )); // Wait for the referrer to defer, then unmap before walk EOF while stats.bootstrap_deferred_bytes.load(Ordering::Relaxed) == 0 { tokio::time::sleep(std::time::Duration::from_millis(2)).await; } - mapping.write().await.clear(); + Arc::make_mut(&mut *mapping.write().await).clear(); drop(tup_tx); let mut rows = Vec::new(); diff --git a/src/emit/pipeline/decode.rs b/src/emit/pipeline/decode.rs index 09720949..4d9e15df 100644 --- a/src/emit/pipeline/decode.rs +++ b/src/emit/pipeline/decode.rs @@ -1,8 +1,8 @@ //! Decode pool — the CPU/IO-parallel stage. //! -//! Each worker pulls a [`DecodeJob`] and per heap detoasts, resolves -//! relation → mapping → table, runs oracle `PgPending` resolution + sampled -//! validation, and routes to the +//! Each worker pulls a [`DecodeJob`] and per heap detoasts, runs oracle +//! `PgPending` resolution, and routes under the attached +//! [`RouteSnapshot`](crate::emit::route::RouteSnapshot) to the //! [`InsertBatcher`](crate::emit::pipeline::batcher). After the xact's last row it //! reports `Placed{seq, rows}`. //! @@ -11,24 +11,21 @@ //! (`project_walshadow_eventual_consistency`). At M=1 dispatch order (hence //! per-table WAL order) is preserved. -use std::collections::hash_map::Entry; use std::sync::Arc; use std::sync::atomic::Ordering; -use tokio::sync::{Mutex, mpsc}; +use tokio::sync::mpsc; use tokio::task::JoinHandle; -use crate::catalog::shadow_catalog::{CatalogError, ShadowCatalog}; -use crate::decode::heap_decoder::{CommittedTuple, DecodedHeap}; +use crate::decode::heap_decoder::CommittedTuple; use crate::emit::ch_emitter::EmitterStats; use crate::emit::pipeline::Fatal; use crate::emit::pipeline::ack::AckHandle; use crate::emit::pipeline::batcher::{BatcherMsg, RoutedRow}; -use crate::mapping::{MappingHandle, TableMapping}; -use crate::ops::oracle::{Oracle, maybe_validate_tuple, resolve_pending_tuple}; -use crate::schema::RelDescriptor; +use crate::emit::route::RoutedHeap; +use crate::ops::oracle::{Oracle, resolve_pending_tuple}; use crate::toast::{ChunkRefMap, ToastResolver}; -use crate::xact::xact_buffer::{ChunkGeneration, RelCache, detoast_heap}; +use crate::xact::xact_buffer::{ChunkGeneration, detoast_heap}; /// `chunks` holds the xact's chunk-map generations (oldest first), each /// immutable once sealed by the drain: batches / barrier segments of one @@ -40,25 +37,23 @@ pub struct DecodeJob { pub seq: u64, pub commit_ts: i64, pub commit_lsn: u64, - pub heaps: Vec, + pub heaps: Vec, pub chunks: Vec>, /// Slice admission permit; shares ride every routed row through the /// batcher to the in-flight insert, releasing post-insert-ack pub permit: Option>, } -/// Shared dependencies a decode worker (or the barrier's inline data path) -/// needs to turn heaps into routed rows. +/// Shared dependencies a decode worker needs to turn routed heaps into +/// batcher rows. Descriptor and route ride each heap's envelope; nothing +/// here resolves catalog or mapping state. #[derive(Clone)] pub struct DecodeCtx { - pub catalog: Arc>, - pub mapping: MappingHandle, pub oracle: Option>, /// Shared FIFO `BatcherMsg` channel: a chunk enqueues as one ordered item /// so a barrier `FlushAll` can't overtake it. pub msg_tx: mpsc::Sender, - /// Decode bumps `foreign_db_rows_skipped` / `unsupported_relations` on the - /// skip arms so the parallel path keeps those metrics live. + /// Throughput counters (`decode_jobs_in` / `decode_rows_out`). pub stats: Arc, /// TOAST chunk store + miss policy for values absent from this xact's /// in-memory chunk map (pre-window re-emits). @@ -84,100 +79,60 @@ async fn route_chunk( .map_err(|_| "batcher channel closed".to_string()) } -/// Detoast, resolve, and route every heap of one xact. Returns rows routed -/// (the `R` the collector compares against). Used by decode workers and the -/// barrier's data segments. +/// Detoast and route every heap of one xact under its attached route. +/// Returns rows routed (the `R` the collector compares against). Used by +/// decode workers and the barrier's data segments. /// /// The buffer is flushed before returning so every routed row is on the /// channel by the time the caller reports `Placed` (watermark invariant). -/// Foreign-database and unmapped relations are skipped (bumping -/// `foreign_db_rows_skipped` / `unsupported_relations`); any other catalog -/// error poisons the stream. +/// Routes attach at the reorder coordinator's planning step; `route = None` +/// (deterministically unmapped, counted there) discards here. #[allow(clippy::too_many_arguments)] pub async fn decode_and_route( ctx: &DecodeCtx, seq: u64, commit_ts: i64, commit_lsn: u64, - heaps: Vec, + heaps: Vec, chunks: Vec>, permit: Option>, - cache: &mut RelCache<(Arc, Arc)>, ) -> Result { - cache.refresh(); let ref_maps: Vec<&ChunkRefMap> = chunks.iter().map(|g| g.map()).collect(); // One spool per xact; generations sealed before spooling carry None let spool = chunks.iter().find_map(|g| g.spool()); let mut routed = 0u64; let mut buf: Vec = Vec::new(); let mut buf_bytes = 0usize; - for mut heap in heaps { - let value_permit = detoast_heap( - &mut heap, - spool, - &ref_maps, - &ctx.catalog, - true, - &ctx.resolver, - ) - .await - .map_err(|e| e.to_string())? - .map(Arc::new); - // Cache hit: no shared catalog lock, no mapping read. Skip/error arms - // are never cached, so `foreign_db_rows_skipped`/`unsupported_relations` - // still count per row. - let (rel, mapping) = match cache.entry(heap.rfn) { - Entry::Occupied(e) => e.get().clone(), - Entry::Vacant(slot) => { - let rel = match crate::catalog::shadow_catalog::resolve_at_pooled( - &ctx.catalog, - heap.rfn, - heap.source_lsn, - ) - .await - { - Ok(r) => r, - // Physical WAL carries the whole cluster; skip foreign-DB rows - Err(CatalogError::ForeignDatabase(_)) => { - ctx.stats - .foreign_db_rows_skipped - .fetch_add(1, Ordering::Relaxed); - continue; - } - Err(e) => return Err(e.to_string()), - }; - let Some(mapping) = - crate::emit::pipeline::lookup_mapping(&ctx.mapping, &rel.rel_name, &ctx.stats) - .await - else { - continue; - }; - slot.insert((rel, mapping)).clone() - } + for envelope in heaps { + // Discard precedes detoast: unrouted values never hit the resolver + let Some(route) = envelope.route else { + continue; }; + let mut heap = envelope.described; + let value_permit = detoast_heap(&mut heap, spool, &ref_maps, &ctx.resolver) + .await + .map_err(|e| e.to_string())? + .map(Arc::new); + let rel = heap.descriptor.clone(); let mut committed = CommittedTuple { - decoded: heap, + decoded: heap.decoded, commit_ts, commit_lsn, }; if let Some(oracle) = &ctx.oracle { - // Resolve PgPending via shadow PG extension, then fire the 1-in-N - // validator probe + // Resolve PgPending via shadow PG extension if let Some(t) = committed.decoded.new.as_mut() { resolve_pending_tuple(oracle, &mut t.columns).await; } if let Some(t) = committed.decoded.old.as_mut() { resolve_pending_tuple(oracle, &mut t.columns).await; } - if let Some(t) = committed.decoded.new.as_ref() { - maybe_validate_tuple(oracle, &t.columns).await; - } } buf_bytes += committed.decoded.approx_bytes(); buf.push(RoutedRow { seq, rel, - mapping, + route, committed, permit: permit.clone(), value_permit, @@ -213,9 +168,6 @@ pub fn spawn_pool( let ack = ack.clone(); let fatal = fatal.clone(); handles.push(tokio::spawn(async move { - // Per-worker descriptor cache; epoch handle read once at startup. - let epoch = ctx.catalog.lock().await.invalidation_epoch_handle(); - let mut cache = RelCache::new(epoch); while let Ok(job) = jobs.recv().await { ctx.stats.decode_jobs_in.fetch_add(1, Ordering::Relaxed); let seq = job.seq; @@ -227,7 +179,6 @@ pub fn spawn_pool( job.heaps, job.chunks, job.permit, - &mut cache, ) .await { diff --git a/src/emit/pipeline/mod.rs b/src/emit/pipeline/mod.rs index 5e86bbd4..148cbcac 100644 --- a/src/emit/pipeline/mod.rs +++ b/src/emit/pipeline/mod.rs @@ -15,6 +15,8 @@ pub mod batcher; pub mod bootstrap; pub mod decode; pub mod inserter; +pub mod plan_spool; +pub mod planner; pub mod reorder; pub mod tail; @@ -29,10 +31,9 @@ use crate::catalog::shadow_catalog::ShadowCatalog; use crate::ch::EmitterError; use crate::emit::ch_ddl::DdlApplicator; use crate::emit::ch_emitter::{EmitterConfig, EmitterStats}; -use crate::mapping::{MappingHandle, TableMapping}; +use crate::mapping::MappingHandle; use crate::ops::oracle::Oracle; use crate::ops::trace::TxnSpanRegistry; -use crate::schema::{RelName, SchemaEventRx}; use crate::xact::xact_buffer::{SubxactTracker, XactBuffer}; /// One-shot fatal-error signal shared across pipeline stages. Pump polls @@ -91,24 +92,6 @@ impl Fatal { /// without it a cold table's rows pin the watermark indefinitely. const DEFAULT_PIPELINE_FLUSH: Duration = Duration::from_millis(100); -/// Resolve a relation's destination mapping. Bumps `unsupported_relations` -/// and returns None when the relation maps to no destination -pub(crate) async fn lookup_mapping( - mapping: &MappingHandle, - rel: &RelName, - stats: &EmitterStats, -) -> Option> { - match mapping.read().await.get(rel) { - Some(v) => Some(Arc::new(v.clone())), - None => { - stats - .unsupported_relations - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - None - } - } -} - /// Tail selection: `ClickHouse` ships sealed blocks over N connections; /// `Null` acks at swallow — metrics-only runs, zero CH connections. pub enum TailKind { @@ -130,10 +113,9 @@ pub struct PipelineConfig { pub tail: TailKind, pub buffer: Arc>, pub subxact_tracker: Arc>, - pub schema_events: Option, - /// Same handle as the decoder's `with_catalog_signals` armer; reorder - /// consumes at the arming xact's commit - pub pending_sweeps: Option, + /// Durable descriptor log: decode pool + reorder read interval-scoped + /// descriptors from it + pub log: 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`. @@ -208,8 +190,7 @@ impl PipelineConfig { tail, buffer, subxact_tracker, - schema_events, - pending_sweeps, + log, stats, span_registry, config_resolver, @@ -256,8 +237,6 @@ impl PipelineConfig { let (jobs_tx, jobs_rx) = async_channel::bounded::((m * 4).max(8)); let ctx = decode::DecodeCtx { - catalog: catalog.clone(), - mapping, oracle, msg_tx: msg_tx.clone(), stats: stats.clone(), @@ -266,12 +245,12 @@ impl PipelineConfig { }; let decoders = decode::spawn_pool(m, ctx, jobs_rx, ack.clone(), fatal.clone()); + let plan_dir = buffer.lock().await.spill_dir().to_path_buf(); let reorder = reorder::ReorderSink::new( buffer, + log, catalog, subxact_tracker, - schema_events, - pending_sweeps, applicator, ack, jobs_tx, @@ -284,9 +263,13 @@ impl PipelineConfig { span_registry, emitter.drain_batch_rows, emitter.drain_batch_bytes, + emitter.plan_disk_max, + plan_dir, Some(budget.clone()), retires, resume_floor, + mapping, + emitter.soft_delete, ); Ok(( diff --git a/src/emit/pipeline/plan_spool.rs b/src/emit/pipeline/plan_spool.rs new file mode 100644 index 00000000..454b55e7 --- /dev/null +++ b/src/emit/pipeline/plan_spool.rs @@ -0,0 +1,818 @@ +//! Transient validated plan spool — side-effect-free transaction planning. +//! +//! Planning writes one plan per committed transaction: high-volume heap +//! entries stream through a checksummed spool file while bounded metadata +//! stays resident in the plan header — descriptor dictionary, route table, +//! control entries — matching the live buffer's position that control +//! state per xact stays small. Replay after a successful seal walks heaps +//! and controls in original order with the event-before-heap tie break; +//! any planning error drops the writer and the file unlinks, so the +//! transaction emits nothing. Files are transient and source-WAL +//! reconstructible: never durable, removed at startup by +//! [`clean_plan_files`] via the spill-dir clear. +//! +//! Frame layout after the 4-byte `magic + version` header: +//! `[len:u32][crc32c:u32][body]`, body tag 0 = heap +//! (`dict_id:u32 + route_id:u32 + heap bytes`, spill codec), tag 1 = seal +//! (`heap_count:u64`). A missing seal means planning never finished; +//! trailing bytes after it mean corruption. `route_id == u32::MAX` is the +//! deterministically unmapped row (planned counted discard). + +use std::collections::HashMap; +use std::fs::{self, File}; +use std::io::{BufReader, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use thiserror::Error; + +use crate::decode::heap_decoder::DescribedHeap; +use crate::emit::route::{RouteSnapshot, RoutedHeap}; +use crate::schema::RelDescriptor; +use crate::xact::spill::{self, Cursor, SpillError}; +use crate::xact::xact_buffer::{DrainEntry, OrderedEvent, ToastRowBatch}; + +/// ASCII for `xxd`-friendly debug +pub const PLAN_MAGIC: [u8; 2] = *b"WP"; +pub const PLAN_VERSION: u16 = 1; +/// Spool file suffix, startup-cleanup key +pub const PLAN_SUFFIX: &str = ".plan"; + +/// Plans at or below this stay memory-resident: the common +/// single-statement commit never touches the filesystem. Larger plans +/// spill to the file at `path` (bounded by the disk cap) +pub const DEFAULT_PLAN_MEM_MAX: u64 = 1 << 20; + +const TAG_HEAP: u8 = 0; +const TAG_SEAL: u8 = 1; +/// `route_id` sentinel for unmapped rows +const ROUTE_NONE: u32 = u32::MAX; + +#[derive(Debug, Error)] +pub enum PlanSpoolError { + #[error("plan spool io: {0}")] + Io(#[from] std::io::Error), + #[error("plan spool format: {detail}")] + Format { detail: String }, + #[error("plan frame checksum mismatch at offset {offset}")] + Corrupt { offset: u64 }, + #[error("plan spool {needed} bytes exceeds disk budget {cap}")] + DiskBudget { needed: u64, cap: u64 }, + #[error("plan spool ends without seal; planning did not finish")] + Unsealed, +} + +impl From for PlanSpoolError { + fn from(e: SpillError) -> Self { + match e { + SpillError::Io(e) => Self::Io(e), + e => Self::Format { + detail: e.to_string(), + }, + } + } +} + +pub type Result = std::result::Result; + +/// Streams one transaction's validated heaps to disk while accumulating the +/// bounded plan header. Dropped unsealed (any planning error), the file +/// unlinks and nothing replays +enum PlanSink { + Mem(Vec), + File(BufWriter), +} + +impl PlanSink { + fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> { + match self { + Self::Mem(v) => { + v.extend_from_slice(buf); + Ok(()) + } + Self::File(f) => f.write_all(buf), + } + } +} + +pub struct PlanWriter { + sink: PlanSink, + path: PathBuf, + /// File exists on disk (spilled); unsealed drop unlinks only then + spilled: bool, + mem_max: u64, + cap: u64, + bytes: u64, + heap_count: u64, + /// Heaps pushed with a route (unmapped discards excluded) + routed_count: u64, + sealed: bool, + /// Dictionary ids by `(descriptor Arc identity, valid_from)`; the held + /// Arc in `descriptors` keeps each pointer live and unique + desc_ids: HashMap<(usize, u64), u32>, + descriptors: Vec<(Arc, u64)>, + route_ids: HashMap, + routes: Vec>, + controls: Vec, + row_batches: Vec, + truncate_rows: Vec, + scratch: Vec, +} + +impl PlanWriter { + /// Starts memory-resident; the file at `path` is only created if the + /// plan outgrows `mem_max` + pub fn create(path: PathBuf, cap: u64, mem_max: u64) -> Result { + let mut sink = PlanSink::Mem(Vec::new()); + sink.write_all(&PLAN_MAGIC)?; + sink.write_all(&PLAN_VERSION.to_le_bytes())?; + Ok(Self { + sink, + path, + spilled: false, + mem_max, + cap, + bytes: 4, + heap_count: 0, + routed_count: 0, + sealed: false, + desc_ids: HashMap::new(), + descriptors: Vec::new(), + route_ids: HashMap::new(), + routes: Vec::new(), + controls: Vec::new(), + row_batches: Vec::new(), + truncate_rows: Vec::new(), + scratch: Vec::new(), + }) + } + + /// Disk bytes written so far, header included + pub fn bytes(&self) -> u64 { + self.bytes + } + + /// Append one validated heap with the route it planned under. Descriptor + /// and route dedup by Arc identity into the plan header + pub fn push_heap( + &mut self, + heap: &DescribedHeap, + route: Option<&Arc>, + ) -> Result<()> { + let desc_key = ( + Arc::as_ptr(&heap.descriptor) as usize, + heap.descriptor_valid_from, + ); + let next_id = self.descriptors.len() as u32; + let dict_id = *self.desc_ids.entry(desc_key).or_insert_with(|| { + self.descriptors + .push((heap.descriptor.clone(), heap.descriptor_valid_from)); + next_id + }); + let route_id = if let Some(r) = route { + let next_id = self.routes.len() as u32; + *self + .route_ids + .entry(Arc::as_ptr(r) as usize) + .or_insert_with(|| { + self.routes.push(r.clone()); + next_id + }) + } else { + ROUTE_NONE + }; + let mut body = std::mem::take(&mut self.scratch); + body.clear(); + body.push(TAG_HEAP); + body.extend_from_slice(&dict_id.to_le_bytes()); + body.extend_from_slice(&route_id.to_le_bytes()); + spill::encode_heap_into(&mut body, &heap.decoded); + let res = self.write_frame(&body, false); + self.scratch = body; + res?; + self.heap_count += 1; + if route.is_some() { + self.routed_count += 1; + } + Ok(()) + } + + /// Pin a control entry before the next heap: replay yields it ahead of + /// heap `heap_count`, preserving the drain's event-before-heap tie break. + /// `row_idx` is the plan-global mirror-row position it fences + pub fn push_control(&mut self, event: DrainEntry, row_idx: usize) { + self.controls.push(OrderedEvent { + heap_idx: self.heap_count as usize, + row_idx, + event, + }); + } + + /// Carry one batch's mirror-row refs; already gauged, released when the + /// plan drops. Control `row_idx` / truncate fences index the + /// concatenation of pushed batches + pub fn push_rows(&mut self, rows: ToastRowBatch) { + if !rows.is_empty() { + self.row_batches.push(rows); + } + } + + /// Mirror-row fence for the next `HeapOp::Truncate` heap: executor puts + /// rows up to `upto` before applying the truncate + pub fn note_truncate_cursor(&mut self, upto: usize) { + self.truncate_rows.push(upto); + } + + /// Write the seal frame and freeze the header. Seal is exempt from the + /// disk budget so a boundary-sized transaction still closes cleanly + pub fn seal(mut self, commit_lsn: u64, commit_ts: i64) -> Result { + let mut body = std::mem::take(&mut self.scratch); + body.clear(); + body.push(TAG_SEAL); + body.extend_from_slice(&self.heap_count.to_le_bytes()); + self.write_frame(&body, true)?; + let store = match &mut self.sink { + PlanSink::Mem(buf) => PlanStore::Mem(std::mem::take(buf)), + PlanSink::File(f) => { + f.flush()?; + PlanStore::File(std::mem::take(&mut self.path)) + } + }; + self.sealed = true; + Ok(SealedPlan { + store, + descriptors: std::mem::take(&mut self.descriptors), + routes: std::mem::take(&mut self.routes), + controls: std::mem::take(&mut self.controls), + row_batches: std::mem::take(&mut self.row_batches), + truncate_rows: std::mem::take(&mut self.truncate_rows), + heap_count: self.heap_count, + routed_count: self.routed_count, + size_bytes: self.bytes, + commit_lsn, + commit_ts, + }) + } + + fn write_frame(&mut self, body: &[u8], budget_exempt: bool) -> Result<()> { + let needed = self.bytes + 8 + body.len() as u64; + if !budget_exempt && needed > self.cap { + return Err(PlanSpoolError::DiskBudget { + needed, + cap: self.cap, + }); + } + if needed > self.mem_max + && let PlanSink::Mem(buf) = &self.sink + { + let mut f = BufWriter::new(File::create(&self.path)?); + f.write_all(buf)?; + self.sink = PlanSink::File(f); + self.spilled = true; + } + self.sink.write_all(&(body.len() as u32).to_le_bytes())?; + self.sink.write_all(&crc32c::crc32c(body).to_le_bytes())?; + self.sink.write_all(body)?; + self.bytes = needed; + Ok(()) + } +} + +impl Drop for PlanWriter { + fn drop(&mut self) { + if !self.sealed && self.spilled { + let _ = fs::remove_file(&self.path); + } + } +} + +/// Backing bytes of a sealed plan: memory for small plans, the spool +/// file once spilled +enum PlanStore { + Mem(Vec), + File(PathBuf), +} + +/// Complete validated plan: bounded header plus the sealed frame bytes. +/// A spilled plan unlinks its file on drop — plans are transient, +/// replay-once state +pub struct SealedPlan { + store: PlanStore, + /// Dictionary in first-use order, `(descriptor, valid_from)` + pub descriptors: Vec<(Arc, u64)>, + /// Route table in first-use order + pub routes: Vec>, + /// Plan-global positions: entry at `heap_idx` replays before that heap + pub controls: Vec, + /// Mirror-row refs in plan order, still gauged; `controls[..].row_idx` + /// and `truncate_rows` index the concatenation + pub row_batches: Vec, + /// Plan-global mirror-row fence per `HeapOp::Truncate` heap, heap order + pub truncate_rows: Vec, + pub heap_count: u64, + /// `heap_count` minus unmapped discards; feeds `xact_plan_rows` + pub routed_count: u64, + /// Sealed frame bytes, header + seal included; feeds `xact_plan_bytes` + pub size_bytes: u64, + pub commit_lsn: u64, + pub commit_ts: i64, +} + +impl SealedPlan { + /// Spool file path; `None` while memory-resident + pub fn path(&self) -> Option<&Path> { + match &self.store { + PlanStore::Mem(_) => None, + PlanStore::File(p) => Some(p), + } + } + + pub fn replay(&self) -> Result> { + let mut input: Box = match &self.store { + PlanStore::Mem(buf) => Box::new(std::io::Cursor::new(buf.as_slice())), + PlanStore::File(p) => Box::new(BufReader::new(File::open(p)?)), + }; + let mut header = [0u8; 4]; + read_or_unsealed(&mut input, &mut header)?; + if header[..2] != PLAN_MAGIC { + return Err(PlanSpoolError::Format { + detail: "bad plan magic".into(), + }); + } + let version = u16::from_le_bytes([header[2], header[3]]); + if version != PLAN_VERSION { + return Err(PlanSpoolError::Format { + detail: format!("unsupported plan version {version}, expected {PLAN_VERSION}"), + }); + } + Ok(PlanReader { + plan: self, + input, + offset: 4, + next_heap: 0, + next_control: 0, + done: false, + }) + } + + /// Walk every frame without acting on it: checksums, id resolution, + /// seal cross-check. Executors verify file-backed plans before the + /// first side effect so disk corruption fails the whole transaction + /// instead of a prefix emitting + pub fn verify(&self) -> Result<()> { + let mut rd = self.replay()?; + while rd.next_item()?.is_some() {} + Ok(()) + } +} + +impl Drop for SealedPlan { + fn drop(&mut self) { + if let PlanStore::File(p) = &self.store { + let _ = fs::remove_file(p); + } + } +} + +/// One replayed plan step, original drain order +pub enum PlanItem<'p> { + Control(&'p OrderedEvent), + Heap(RoutedHeap), +} + +/// Linear replay over a [`SealedPlan`]: verifies every frame checksum, +/// resolves dictionary/route ids against the header, interleaves controls +/// at their pinned positions +pub struct PlanReader<'p> { + plan: &'p SealedPlan, + input: Box, + offset: u64, + next_heap: u64, + next_control: usize, + done: bool, +} + +impl<'p> PlanReader<'p> { + pub fn next_item(&mut self) -> Result>> { + if let Some(c) = self.plan.controls.get(self.next_control) + && c.heap_idx as u64 <= self.next_heap + { + self.next_control += 1; + return Ok(Some(PlanItem::Control(c))); + } + if self.done { + return Ok(None); + } + let frame_offset = self.offset; + let mut fixed = [0u8; 8]; + read_or_unsealed(&mut self.input, &mut fixed)?; + let len = u32::from_le_bytes(fixed[..4].try_into().unwrap()) as usize; + let crc = u32::from_le_bytes(fixed[4..].try_into().unwrap()); + // Length sits outside the body checksum, so bound it against the + // sealed size before allocating: garbage reads as format error + // instead of a multi-gigabyte reservation + let remaining = self.plan.size_bytes.saturating_sub(frame_offset + 8); + if len as u64 > remaining { + return Err(PlanSpoolError::Format { + detail: format!( + "frame at {frame_offset} claims {len} bytes, {remaining} left in plan" + ), + }); + } + let mut body = vec![0u8; len]; + read_or_unsealed(&mut self.input, &mut body)?; + self.offset += 8 + len as u64; + if crc32c::crc32c(&body) != crc { + return Err(PlanSpoolError::Corrupt { + offset: frame_offset, + }); + } + let format = |detail: String| PlanSpoolError::Format { detail }; + match body.first() { + Some(&TAG_HEAP) => { + let mut c = Cursor::new(&body[1..]); + let dict_id = c.u32()? as usize; + let route_id = c.u32()?; + let decoded = spill::decode_heap(&mut c)?; + if c.remaining() != 0 { + return Err(format(format!( + "heap frame at {frame_offset} has {} trailing bytes", + c.remaining(), + ))); + } + let Some((descriptor, valid_from)) = self.plan.descriptors.get(dict_id).cloned() + else { + return Err(format(format!( + "heap references dict id {dict_id}, header has {}", + self.plan.descriptors.len(), + ))); + }; + let route = if route_id == ROUTE_NONE { + None + } else { + let Some(r) = self.plan.routes.get(route_id as usize) else { + return Err(format(format!( + "heap references route id {route_id}, header has {}", + self.plan.routes.len(), + ))); + }; + Some(r.clone()) + }; + self.next_heap += 1; + Ok(Some(PlanItem::Heap(RoutedHeap { + described: DescribedHeap { + decoded, + descriptor, + descriptor_valid_from: valid_from, + }, + route, + }))) + } + Some(&TAG_SEAL) => { + let count = u64::from_le_bytes( + body.get(1..9) + .ok_or_else(|| format("short seal frame".into()))? + .try_into() + .unwrap(), + ); + if count != self.next_heap || count != self.plan.heap_count { + return Err(format(format!( + "seal count {count} vs replayed {} / header {}", + self.next_heap, self.plan.heap_count, + ))); + } + let mut probe = [0u8; 1]; + if self.input.read(&mut probe)? != 0 { + return Err(format("trailing bytes after seal".into())); + } + self.done = true; + self.next_item() + } + Some(&tag) => Err(format(format!("unknown plan frame tag {tag}"))), + None => Err(format("empty plan frame".into())), + } + } +} + +/// EOF anywhere before the seal frame means planning never finished +fn read_or_unsealed(r: &mut impl Read, buf: &mut [u8]) -> Result<()> { + r.read_exact(buf).map_err(|e| { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + PlanSpoolError::Unsealed + } else { + e.into() + } + }) +} + +/// Startup cleanup: plans are transient, any survivor is a crash leftover. +/// Returns removed count +pub fn clean_plan_files(dir: &Path) -> std::io::Result { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(e) => return Err(e), + }; + let mut removed = 0; + for entry in entries { + let p = entry?.path(); + if p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|s| s.ends_with(PLAN_SUFFIX)) + { + fs::remove_file(&p)?; + removed += 1; + } + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::decode::heap_decoder::{ColumnValue, DecodedHeap, DecodedTuple, HeapOp}; + use crate::mapping::{TableMapping, TableTarget}; + use crate::schema::{INT4OID, RelAttr, RelDescriptor, RelName, SchemaEvent}; + use walrus::pg::walparser::RelFileNode; + + fn descriptor(rel_node: u32) -> Arc { + Arc::new(RelDescriptor { + rfn: RelFileNode { + spc_node: 1663, + db_node: 5, + rel_node, + }, + oid: rel_node, + toast_oid: 0, + namespace_oid: 2200, + rel_name: RelName::new("public", &format!("t{rel_node}")), + kind: 'r', + persistence: 'p', + replident: crate::schema::ReplIdent::Full { pk_attnums: None }, + attributes: vec![RelAttr { + attnum: 1, + name: "id".into(), + type_oid: INT4OID, + typmod: -1, + not_null: false, + dropped: false, + type_name: "int4".into(), + type_byval: true, + type_len: 4, + type_align: 'i', + type_storage: 'p', + missing_text: None, + }], + }) + } + + fn heap(rel: &Arc, lsn: u64, v: i32) -> DescribedHeap { + DescribedHeap { + decoded: DecodedHeap { + rfn: rel.rfn, + xid: 7, + source_lsn: lsn, + op: HeapOp::Insert, + new: Some(DecodedTuple { + columns: vec![Some(ColumnValue::Int4(v))], + partial: false, + }), + old: None, + }, + descriptor: rel.clone(), + descriptor_valid_from: 0x40, + } + } + + fn route() -> Arc { + RouteSnapshot::freeze( + Arc::new(TableMapping { + target: TableTarget::new("db", "t"), + columns: Vec::new(), + }), + Arc::new(HashMap::new()), + false, + ) + } + + fn dropped(oid: u32) -> DrainEntry { + DrainEntry::Catalog(SchemaEvent::Dropped { + oid, + rel_name: RelName::new("public", &format!("t{oid}")), + }) + } + + /// Replay reproduces write order: controls at their pinned positions + /// (before their heap, trailing after the last), dictionary and route + /// table deduped to one entry each per identity + #[test] + fn round_trip_orders_heaps_and_controls() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("1.plan"); + let mut w = PlanWriter::create(path.clone(), 1 << 20, DEFAULT_PLAN_MEM_MAX).unwrap(); + let (d1, d2) = (descriptor(16500), descriptor(16501)); + let r = route(); + w.push_control(dropped(1), 0); + w.push_heap(&heap(&d1, 100, 10), Some(&r)).unwrap(); + w.push_heap(&heap(&d2, 110, 20), None).unwrap(); + w.push_control(dropped(2), 0); + w.push_heap(&heap(&d1, 120, 30), Some(&r)).unwrap(); + w.push_control(dropped(3), 0); + let plan = w.seal(0x2000, 42).unwrap(); + assert_eq!(plan.descriptors.len(), 2, "dict deduped"); + assert_eq!(plan.routes.len(), 1, "route table deduped"); + assert_eq!(plan.heap_count, 3); + assert_eq!(plan.routed_count, 2, "unmapped discard not counted"); + assert!(plan.size_bytes > 0); + assert_eq!((plan.commit_lsn, plan.commit_ts), (0x2000, 42)); + + let mut rd = plan.replay().unwrap(); + let mut order = Vec::new(); + while let Some(item) = rd.next_item().unwrap() { + order.push(match item { + PlanItem::Control(c) => { + let DrainEntry::Catalog(SchemaEvent::Dropped { oid, .. }) = &c.event else { + panic!("unexpected control"); + }; + format!("e{oid}") + } + PlanItem::Heap(h) => { + assert!( + Arc::ptr_eq(&h.described.descriptor, &plan.descriptors[0].0) + || Arc::ptr_eq(&h.described.descriptor, &plan.descriptors[1].0), + "descriptor resolves through the header table" + ); + if let Some(route) = &h.route { + assert!(Arc::ptr_eq(route, &plan.routes[0])); + } + let new = h.described.decoded.new.as_ref().unwrap(); + let Some(ColumnValue::Int4(v)) = new.columns[0] else { + panic!("unexpected column"); + }; + format!("h{v}{}", if h.route.is_some() { "r" } else { "-" }) + } + }); + } + assert_eq!(order, ["e1", "h10r", "h20-", "e2", "h30r", "e3"]); + assert!(plan.path().is_none(), "small plan stays memory-resident"); + drop(rd); + drop(plan); + assert!(!path.exists(), "no file was ever created"); + } + + /// Oracle-pending values (`PgPending` / `Unsupported`) round-trip + /// byte-identical so post-plan decode resolves them exactly as + /// live-decoded rows. Best-effort policy: no plan-time codec rejection, + /// shadow-PG oracle resolves after replay, may lag row's catalog state + #[test] + fn pending_values_round_trip() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("1.plan"); + let mut w = PlanWriter::create(path, 1 << 20, DEFAULT_PLAN_MEM_MAX).unwrap(); + let d = descriptor(16500); + let cols = vec![ + Some(ColumnValue::PgPending { + type_oid: 3802, + raw: br#"{"k":1}"#.to_vec(), + }), + Some(ColumnValue::Unsupported { + type_oid: 600, + raw: vec![1, 2, 3, 4], + }), + ]; + let mut h = heap(&d, 100, 0); + h.decoded.new.as_mut().unwrap().columns = cols.clone(); + w.push_heap(&h, Some(&route())).unwrap(); + let plan = w.seal(0x2000, 42).unwrap(); + let mut rd = plan.replay().unwrap(); + let Some(PlanItem::Heap(out)) = rd.next_item().unwrap() else { + panic!("expected heap"); + }; + assert_eq!(out.described.decoded.new.unwrap().columns, cols); + assert!(rd.next_item().unwrap().is_none()); + } + + /// Flipped payload byte surfaces as a checksum error, not a bad decode + #[test] + fn corruption_fails_replay() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("1.plan"); + let mut w = PlanWriter::create(path.clone(), 1 << 20, 0).unwrap(); + let d = descriptor(16500); + w.push_heap(&heap(&d, 100, 10), None).unwrap(); + let plan = w.seal(0x2000, 42).unwrap(); + let mut bytes = fs::read(&path).unwrap(); + let mid = 4 + 8 + 5; // into the first frame body + bytes[mid] ^= 0xFF; + fs::write(&path, &bytes).unwrap(); + let mut rd = plan.replay().unwrap(); + let Err(err) = rd.next_item() else { + panic!("expected corruption error"); + }; + assert!(matches!(err, PlanSpoolError::Corrupt { .. }), "{err}"); + } + + /// Frame length lives outside the body checksum: a garbage length is + /// rejected against the sealed size rather than allocated + #[test] + fn oversize_frame_len_fails_before_allocating() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("1.plan"); + let mut w = PlanWriter::create(path.clone(), 1 << 20, 0).unwrap(); + let d = descriptor(16500); + w.push_heap(&heap(&d, 100, 10), None).unwrap(); + let plan = w.seal(0x2000, 42).unwrap(); + let mut bytes = fs::read(&path).unwrap(); + bytes[4..8].copy_from_slice(&u32::MAX.to_le_bytes()); + fs::write(&path, &bytes).unwrap(); + let Err(err) = plan.verify() else { + panic!("expected format error"); + }; + assert!(matches!(err, PlanSpoolError::Format { .. }), "{err}"); + } + + /// Sealed spilled plan verifies clean, fails after post-seal byte flip + #[test] + fn verify_walks_all_frames() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("1.plan"); + let mut w = PlanWriter::create(path.clone(), 1 << 20, 0).unwrap(); + let d = descriptor(16500); + w.push_heap(&heap(&d, 100, 10), None).unwrap(); + w.push_heap(&heap(&d, 110, 20), None).unwrap(); + let plan = w.seal(0x2000, 42).unwrap(); + plan.verify().unwrap(); + let mut bytes = fs::read(&path).unwrap(); + assert_eq!(plan.size_bytes, bytes.len() as u64); + let last_body = bytes.len() - 18; // last heap frame body, before 17-byte seal + bytes[last_body] ^= 0xFF; + fs::write(&path, &bytes).unwrap(); + let Err(err) = plan.verify() else { + panic!("expected corruption error"); + }; + assert!(matches!(err, PlanSpoolError::Corrupt { .. }), "{err}"); + } + + /// Executed (sealed + spilled) plan removes its file on drop + #[test] + fn sealed_plan_drop_unlinks_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("1.plan"); + let mut w = PlanWriter::create(path.clone(), 1 << 20, 0).unwrap(); + let d = descriptor(16500); + w.push_heap(&heap(&d, 100, 10), None).unwrap(); + let plan = w.seal(0x2000, 42).unwrap(); + assert!(path.exists(), "mem_max 0 forced file mode"); + drop(plan); + assert!(!path.exists(), "replayed-once plan unlinks"); + } + + /// Truncated tail (crash mid-planning shape) reads as Unsealed + #[test] + fn missing_seal_fails_replay() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("1.plan"); + let mut w = PlanWriter::create(path.clone(), 1 << 20, 0).unwrap(); + let d = descriptor(16500); + w.push_heap(&heap(&d, 100, 10), None).unwrap(); + let heaps_only = w.bytes(); + let plan = w.seal(0x2000, 42).unwrap(); + let bytes = fs::read(&path).unwrap(); + fs::write(&path, &bytes[..heaps_only as usize]).unwrap(); + let mut rd = plan.replay().unwrap(); + assert!( + matches!(rd.next_item(), Ok(Some(PlanItem::Heap(_)))), + "pre-seal frames intact" + ); + let Err(err) = rd.next_item() else { + panic!("expected unsealed error"); + }; + assert!(matches!(err, PlanSpoolError::Unsealed), "{err}"); + } + + /// Byte cap bounds writes; an unsealed writer unlinks its file on drop + #[test] + fn budget_bounds_writes_and_drop_unlinks() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("1.plan"); + let mut w = PlanWriter::create(path.clone(), 16, 0).unwrap(); + let d = descriptor(16500); + let Err(err) = w.push_heap(&heap(&d, 100, 10), None) else { + panic!("expected budget error"); + }; + assert!(matches!(err, PlanSpoolError::DiskBudget { .. }), "{err}"); + drop(w); + assert!(!path.exists(), "abandoned plan unlinks"); + } + + #[test] + fn cleanup_removes_only_plan_files() { + let tmp = tempfile::tempdir().unwrap(); + for name in ["1.plan", "2.plan", "xid-3.bin"] { + fs::write(tmp.path().join(name), b"x").unwrap(); + } + assert_eq!(clean_plan_files(tmp.path()).unwrap(), 2); + assert!(tmp.path().join("xid-3.bin").exists()); + assert_eq!(clean_plan_files(&tmp.path().join("absent")).unwrap(), 0); + } +} diff --git a/src/emit/pipeline/planner.rs b/src/emit/pipeline/planner.rs new file mode 100644 index 00000000..565981dd --- /dev/null +++ b/src/emit/pipeline/planner.rs @@ -0,0 +1,765 @@ +//! Transaction planner — the side-effect-free planning stage. +//! +//! Consumes committed-drain batches in walk order and streams them into a +//! [`plan_spool`](crate::emit::pipeline::plan_spool) plan: heaps detoast +//! and route at planning so the executor never re-resolves, control +//! entries pin their walk positions, mirror-row refs and truncate fences +//! carry through re-based to plan-global indices. Every input-derived +//! failure (descriptor, decode, toast, route) surfaces before the first +//! transaction side effect. +//! +//! Forbidden side effects are unrepresentable, not merely avoided: the +//! planner holds no ack handle, no batcher channel, no ClickHouse client, +//! and no config applicator. Route state folds into a +//! [`PlanRouteView`] the caller owns; the toast resolver is only read. +//! Any error drops the writer and the plan file unlinks — the transaction +//! emits nothing. +//! +//! Validation coverage, one enforcement point per plan-success guarantee: +//! - descriptor Present and unambiguous → commit-time stash verdicts at +//! the drain, fenced per record against the resolved filenode's ambiguity +//! intervals (`XactBufferError::StashAmbiguous`); a tree that stashed and +//! reached the drain with no verdict installed fails closed too +//! (`MissingStashResolution`) +//! - operation supported with logical tuple data → raw operation policy +//! ([`FailClosedReason`]) +//! - decoded xid matches owning xact/subxact → merge ownership check +//! (`XactBufferError::ForeignXid`) +//! - partial update reconstructs → [`PlanError::PartialUpdate`] here; no +//! reconstruction path exists and PG only elides below +//! `wal_level=logical`, so a routed partial fails the plan +//! - needed toast values resolve deterministically → detoast at planning +//! - mapped output columns have deterministic codecs → plan-time codec +//! policy layers onto this stage (deterministic-codec phase) +//! - route snapshot complete → `RouteSnapshot::freeze` populates every +//! field by construction; `None` is the counted unmapped discard, which +//! skips detoast and codec validation entirely +//! - planned schema transition reproducible → control entries carry +//! `SchemaEvent` values (old + new descriptors) into replay verbatim + +use std::path::PathBuf; +use std::sync::Arc; + +use crate::decode::heap_decoder::DescribedHeap; +use crate::emit::pipeline::plan_spool::{ + DEFAULT_PLAN_MEM_MAX, PlanSpoolError, PlanWriter, SealedPlan, +}; +use crate::emit::route::RouteSnapshot; +use crate::toast::{ChunkRefMap, ToastResolver}; +use crate::xact::xact_buffer::{ + DrainEntry, DrainWalk, DrainedBatch, FailClosedReason, WalkStep, XactBufferError, detoast_heap, +}; + +/// Planner's window onto route state. Implementations resolve from frozen +/// versions and fold in-walk control entries into their LOCAL view only — +/// global mapping/config/ClickHouse changes belong to the executor at +/// replay. `route_for` returning `None` is the deterministic unmapped +/// discard, counted by the implementation +pub trait PlanRouteView { + fn route_for(&mut self, heap: &DescribedHeap) -> Option>; + /// Fold one in-walk control entry into the local view. Async so + /// implementations can read shared config, never mutate it + fn apply( + &mut self, + entry: &DrainEntry, + ) -> impl std::future::Future> + Send; +} + +#[derive(Debug, thiserror::Error)] +pub enum PlanError { + #[error("plan spool: {0}")] + Spool(#[from] PlanSpoolError), + #[error("planning detoast: {0}")] + Detoast(#[from] XactBufferError), + /// New-tuple prefix/suffix elision references a predecessor image no + /// reconstruction path provides; PG only elides below wal_level=logical + #[error("partial update at {lsn:#X} lacks reconstructable predecessor")] + PartialUpdate { lsn: u64 }, + #[error("route view: {0}")] + View(String), +} + +impl PlanError { + /// `reason=` label for `xact_plan_failures_total` + pub fn reason(&self) -> &'static str { + match self { + Self::Spool(_) => "spool", + Self::Detoast(e) => drain_reason(e), + Self::PartialUpdate { .. } => "partial_update", + Self::View(_) => "view", + } + } +} + +/// `reason=` label for drain-side errors aborting a plan. Fail-closed +/// verdicts split by cause and the intentional sentinels get their own +/// labels: collapsing them into one bucket left an operator unable to tell +/// an unmodelled WAL op from a spill I/O error +pub fn drain_reason(e: &XactBufferError) -> &'static str { + match e { + XactBufferError::OrdinaryFailClosed { reason, .. } => match reason { + FailClosedReason::ImageOnly => "fail_closed_image_only", + FailClosedReason::Malformed(_) => "fail_closed_malformed", + FailClosedReason::UnsupportedOperation => "fail_closed_unsupported_op", + // Same class as PlanError::PartialUpdate, different stage + FailClosedReason::PartialUpdate => "partial_update", + }, + XactBufferError::StashAmbiguous { .. } => "stash_ambiguous", + XactBufferError::IncompleteToastGeneration { .. } => "incomplete_toast", + XactBufferError::MissingStashResolution { .. } => "missing_stash_resolution", + XactBufferError::Detoast(_) | XactBufferError::ValueTooLarge { .. } => "detoast", + _ => "drain", + } +} + +/// Streams one committed transaction's walk into a sealed plan +pub struct Planner<'a, V> { + writer: PlanWriter, + view: &'a mut V, + resolver: &'a ToastResolver, + /// Mirror rows carried by earlier batches; batch-relative row indices + /// re-base against this + rows_base: usize, +} + +impl<'a, V: PlanRouteView> Planner<'a, V> { + pub fn create( + path: PathBuf, + disk_cap: u64, + view: &'a mut V, + resolver: &'a ToastResolver, + ) -> Result { + Ok(Self { + writer: PlanWriter::create(path, disk_cap, DEFAULT_PLAN_MEM_MAX)?, + view, + resolver, + rows_base: 0, + }) + } + + /// Fold one drained batch. Chunk generations release with the batch — + /// detoast happens here, the executor never re-resolves values + pub async fn plan_batch(&mut self, batch: DrainedBatch) -> Result<(), PlanError> { + let DrainWalk { + steps, + chunks, + new_rows, + is_final: _, + } = batch.into_walk(); + let ref_maps: Vec<&ChunkRefMap> = chunks.iter().map(|g| g.map()).collect(); + // One spool per xact; generations sealed before spooling carry None + let spool = chunks.iter().find_map(|g| g.spool()); + // Batch-relative mirror rows sealed so far + let mut cursor = 0usize; + for step in steps { + match step { + WalkStep::Rows { upto } => cursor = cursor.max(upto), + WalkStep::Event(e) => { + self.view.apply(&e).await.map_err(PlanError::View)?; + self.writer.push_control(e, self.rows_base + cursor); + } + WalkStep::Truncate(heap) => { + self.writer.note_truncate_cursor(self.rows_base + cursor); + let route = self.view.route_for(&heap); + self.writer.push_heap(&heap, route.as_ref())?; + } + WalkStep::Heap(mut heap) => { + // Route before validation and detoast, matching the + // decode pool: unmapped rows discard without touching + // the resolver or codec checks. The value permit drops + // once bytes land in the plan file + let route = self.view.route_for(&heap); + if route.is_some() { + if heap.decoded.new.as_ref().is_some_and(|t| t.partial) { + return Err(PlanError::PartialUpdate { + lsn: heap.decoded.source_lsn, + }); + } + detoast_heap(&mut heap, spool, &ref_maps, self.resolver).await?; + } + self.writer.push_heap(&heap, route.as_ref())?; + } + } + } + self.rows_base += new_rows.len(); + self.writer.push_rows(new_rows); + Ok(()) + } + + /// Freeze the validated plan for the executor + pub fn seal(self, commit_lsn: u64, commit_ts: i64) -> Result { + Ok(self.writer.seal(commit_lsn, commit_ts)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::decode::heap_decoder::{ + ColumnValue, DecodedHeap, DecodedTuple, HeapOp, ToastPointer, + }; + use crate::emit::pipeline::plan_spool::PlanItem; + use crate::mapping::{TableMapping, TableTarget}; + use crate::schema::{INT4OID, RelAttr, RelDescriptor, RelName, SchemaEvent, TEXTOID}; + use crate::xact::xact_buffer::raw_fixtures::{ + inject_ordinary, int4_descriptor, multi_insert_raw, + }; + use crate::xact::xact_buffer::{FailClosedReason, XactBuffer, XactBufferConfig}; + use std::collections::{HashMap, HashSet}; + use walrus::pg::walparser::RelFileNode; + + /// Pins the `reason=` vocabulary `bump_plan_failure` and the metrics + /// render both key on + #[test] + fn plan_error_reason_labels() { + assert_eq!( + PlanError::PartialUpdate { lsn: 1 }.reason(), + "partial_update" + ); + assert_eq!(PlanError::View("x".into()).reason(), "view"); + let fail_closed = |reason| XactBufferError::OrdinaryFailClosed { + relid: 1, + lsn: 2, + rm: 10, + info: 0, + reason, + }; + assert_eq!( + drain_reason(&fail_closed(FailClosedReason::ImageOnly)), + "fail_closed_image_only" + ); + assert_eq!( + drain_reason(&fail_closed(FailClosedReason::Malformed("x".into()))), + "fail_closed_malformed" + ); + assert_eq!( + drain_reason(&fail_closed(FailClosedReason::UnsupportedOperation)), + "fail_closed_unsupported_op" + ); + // Drain-side partial update shares the planner's label + assert_eq!( + drain_reason(&fail_closed(FailClosedReason::PartialUpdate)), + "partial_update" + ); + assert_eq!( + drain_reason(&XactBufferError::StashAmbiguous { + rel_node: 1, + lsn: 2, + from_lsn: 1, + through_lsn: 3, + }), + "stash_ambiguous" + ); + assert_eq!( + drain_reason(&XactBufferError::IncompleteToastGeneration { relid: 1 }), + "incomplete_toast" + ); + assert_eq!( + drain_reason(&XactBufferError::MissingStashResolution { top_xid: 7 }), + "missing_stash_resolution" + ); + assert_eq!( + drain_reason(&XactBufferError::ForeignXid { xid: 1, top: 2 }), + "drain" + ); + assert_eq!( + PlanError::Detoast(XactBufferError::Detoast("x".into())).reason(), + "detoast" + ); + assert_eq!( + drain_reason(&XactBufferError::ForeignXid { xid: 1, top: 2 }), + "drain" + ); + } + + fn descriptor(rel_node: u32, type_oid: u32) -> Arc { + Arc::new(RelDescriptor { + rfn: RelFileNode { + spc_node: 1663, + db_node: 5, + rel_node, + }, + oid: rel_node, + toast_oid: 0, + namespace_oid: 2200, + rel_name: RelName::new("public", &format!("t{rel_node}")), + kind: 'r', + persistence: 'p', + replident: crate::schema::ReplIdent::Full { pk_attnums: None }, + attributes: vec![RelAttr { + attnum: 1, + name: "v".into(), + type_oid, + typmod: -1, + not_null: false, + dropped: false, + type_name: if type_oid == INT4OID { "int4" } else { "text" }.into(), + type_byval: type_oid == INT4OID, + type_len: if type_oid == INT4OID { 4 } else { -1 }, + type_align: 'i', + type_storage: if type_oid == INT4OID { 'p' } else { 'x' }, + missing_text: None, + }], + }) + } + + fn heap(rel: &Arc, lsn: u64, col: ColumnValue) -> DescribedHeap { + DescribedHeap { + decoded: DecodedHeap { + rfn: rel.rfn, + xid: 1, + source_lsn: lsn, + op: HeapOp::Insert, + new: Some(DecodedTuple { + columns: vec![Some(col)], + partial: false, + }), + old: None, + }, + descriptor: rel.clone(), + descriptor_valid_from: 0x40, + } + } + + fn route() -> Arc { + RouteSnapshot::freeze( + Arc::new(TableMapping { + target: TableTarget::new("db", "t"), + columns: Vec::new(), + }), + Arc::new(HashMap::new()), + false, + ) + } + + /// Fixed-map view: routes by rel name until a Dropped event for the oid + /// folds in, then unmapped. Records applied entries + struct MapView { + routes: HashMap>, + dropped: HashSet, + applied: Vec, + } + + impl PlanRouteView for MapView { + fn route_for(&mut self, heap: &DescribedHeap) -> Option> { + if self.dropped.contains(&heap.descriptor.oid) { + return None; + } + self.routes.get(&heap.descriptor.rel_name).cloned() + } + + async fn apply(&mut self, entry: &DrainEntry) -> Result<(), String> { + if let DrainEntry::Catalog(SchemaEvent::Dropped { oid, .. }) = entry { + self.dropped.insert(*oid); + self.applied.push(*oid); + } + Ok(()) + } + } + + fn cfg(dir: std::path::PathBuf) -> XactBufferConfig { + XactBufferConfig { + xact_buffer_max: 1024, + ..XactBufferConfig::new(dir) + } + } + + /// Multi-batch drain plans in walk order: an in-walk Dropped event flips + /// the local view so later heaps plan unmapped; replay reproduces the + /// interleave with routes frozen at planning + #[tokio::test(flavor = "current_thread")] + async fn plans_drain_in_walk_order_with_view_updates() { + let tmp = tempfile::tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = descriptor(16600, INT4OID); + b.on_heap(heap(&rel, 100, ColumnValue::Int4(1))) + .await + .unwrap(); + b.on_heap(heap(&rel, 120, ColumnValue::Int4(2))) + .await + .unwrap(); + b.on_schema_event( + 1, + 150, + SchemaEvent::Dropped { + oid: 16600, + rel_name: rel.rel_name.clone(), + }, + ); + b.on_heap(heap(&rel, 200, ColumnValue::Int4(3))) + .await + .unwrap(); + + let mut view = MapView { + routes: HashMap::from([(rel.rel_name.clone(), route())]), + dropped: HashSet::new(), + applied: Vec::new(), + }; + let resolver = ToastResolver::disabled(); + let mut planner = + Planner::create(tmp.path().join("1.plan"), 1 << 20, &mut view, &resolver).unwrap(); + let mut drain = b.drain_committed(1, 42, 0x2000, &[], false).await.unwrap(); + // 1-row slices force the multi-batch path + while let Some(batch) = drain.next_batch(1, usize::MAX, None).await.unwrap() { + let is_final = batch.is_final; + planner.plan_batch(batch).await.unwrap(); + if is_final { + break; + } + } + drain.finish().await.unwrap(); + let plan = planner.seal(0x2000, 42).unwrap(); + assert_eq!(view.applied, vec![16600], "event folded into local view"); + assert_eq!((plan.commit_lsn, plan.commit_ts), (0x2000, 42)); + + let mut rd = plan.replay().unwrap(); + let mut order = Vec::new(); + while let Some(item) = rd.next_item().unwrap() { + order.push(match item { + PlanItem::Control(_) => "e".to_string(), + PlanItem::Heap(h) => format!( + "h{}{}", + h.described.decoded.source_lsn, + if h.route.is_some() { "r" } else { "-" } + ), + }); + } + assert_eq!( + order, + ["h100r", "h120r", "e", "h200-"], + "post-event heap plans unmapped under the folded view" + ); + } + + /// Unresolvable toast in the LAST row fails planning after earlier + /// valid rows planned: nothing seals, so the executor never runs and + /// the plan file unlinks with the abandoned writer. Store-backed + /// resolver: disabled mode fills placeholders on miss instead + #[tokio::test(flavor = "current_thread")] + async fn detoast_failure_in_last_row_abandons_plan() { + let tmp = tempfile::tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = descriptor(16601, TEXTOID); + b.on_heap(heap(&rel, 90, ColumnValue::Text("ok".into()))) + .await + .unwrap(); + b.on_heap(heap( + &rel, + 100, + ColumnValue::ExternalToast(ToastPointer { + va_rawsize: 64, + va_extinfo: 60, + va_valueid: 9999, + va_toastrelid: 16602, + }), + )) + .await + .unwrap(); + + let mut view = MapView { + routes: HashMap::from([(rel.rel_name.clone(), route())]), + dropped: HashSet::new(), + applied: Vec::new(), + }; + let resolver = ToastResolver::with_store( + Arc::new(crate::toast::MemChunkStore::new()), + Arc::new(crate::emit::ch_emitter::EmitterStats::default()), + ); + let path = tmp.path().join("1.plan"); + let mut planner = Planner::create(path.clone(), 1 << 20, &mut view, &resolver).unwrap(); + let mut drain = b.drain_committed(1, 42, 0x2000, &[], false).await.unwrap(); + // 1-row slices: the valid row plans before the toast row fails + let first = drain + .next_batch(1, usize::MAX, None) + .await + .unwrap() + .expect("valid row slice"); + planner.plan_batch(first).await.unwrap(); + let second = drain + .next_batch(1, usize::MAX, None) + .await + .unwrap() + .expect("toast row slice"); + let Err(err) = planner.plan_batch(second).await else { + panic!("expected detoast failure"); + }; + assert!(matches!(err, PlanError::Detoast(_)), "{err}"); + drain.finish().await.unwrap(); + drop(planner); + assert!(!path.exists(), "failed plan unlinks"); + } + + /// Failing raw record LAST in the walk: earlier rows already planned, + /// the drain's fold error surfaces before seal, so no `SealedPlan` + /// exists for the executor — zero emitter calls for the whole xact + #[tokio::test(flavor = "current_thread")] + async fn failing_last_raw_after_planned_rows_abandons_plan() { + let tmp = tempfile::tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = int4_descriptor(16610); + let rfn = rel.rfn; + b.stash_raw(1, multi_insert_raw(1, 100, 16610, &[1, 2])) + .await + .unwrap(); + let mut bad = multi_insert_raw(1, 120, 16610, &[3]); + bad.main_data[0] = 0; // strip CONTAINS_NEW_TUPLE: ImageOnly on fold + b.stash_raw(1, bad).await.unwrap(); + inject_ordinary(&mut b, rfn, rel.clone()); + + let mut view = MapView { + routes: HashMap::from([(rel.rel_name.clone(), route())]), + dropped: HashSet::new(), + applied: Vec::new(), + }; + let resolver = ToastResolver::disabled(); + let path = tmp.path().join("1.plan"); + let mut planner = Planner::create(path.clone(), 1 << 20, &mut view, &resolver).unwrap(); + let mut drain = b.drain_committed(1, 42, 0x2000, &[], false).await.unwrap(); + let mut planned = 0usize; + // 1-row slices: valid fanout rows plan before the bad record folds + let err = loop { + match drain.next_batch(1, usize::MAX, None).await { + Ok(Some(batch)) => { + planned += batch.heaps.len(); + planner.plan_batch(batch).await.unwrap(); + } + Ok(None) => panic!("expected fold failure before EOF"), + Err(e) => break e, + } + }; + assert!(planned >= 1, "earlier valid rows planned before failure"); + assert!( + matches!( + &err, + XactBufferError::OrdinaryFailClosed { + reason: FailClosedReason::ImageOnly, + .. + } + ), + "{err}" + ); + drop(planner); + assert!(!path.exists(), "abandoned plan unlinks, nothing to execute"); + } + + /// Transaction larger than the buffer memory budget validates through + /// disk spool end to end: xact rows spill at the 1 KiB budget, plan + /// bytes cross the mem threshold into file mode, replay yields every + /// row routed + #[tokio::test(flavor = "current_thread")] + async fn oversize_xact_validates_through_disk_spools() { + let tmp = tempfile::tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = descriptor(16612, INT4OID); + const ROWS: usize = 32 * 1024; + for i in 0..ROWS { + b.on_heap(heap(&rel, 100 + i as u64, ColumnValue::Int4(i as i32))) + .await + .unwrap(); + } + + let mut view = MapView { + routes: HashMap::from([(rel.rel_name.clone(), route())]), + dropped: HashSet::new(), + applied: Vec::new(), + }; + let resolver = ToastResolver::disabled(); + let mut planner = + Planner::create(tmp.path().join("1.plan"), 1 << 30, &mut view, &resolver).unwrap(); + let mut drain = b.drain_committed(1, 42, 0x2000, &[], false).await.unwrap(); + while let Some(batch) = drain.next_batch(1024, usize::MAX, None).await.unwrap() { + let is_final = batch.is_final; + planner.plan_batch(batch).await.unwrap(); + if is_final { + break; + } + } + drain.finish().await.unwrap(); + let plan = planner.seal(0x2000, 42).unwrap(); + assert!( + plan.path().is_some(), + "plan crossed mem threshold into file spool" + ); + plan.verify().unwrap(); + let mut rd = plan.replay().unwrap(); + let mut rows = 0usize; + while let Some(item) = rd.next_item().unwrap() { + let PlanItem::Heap(h) = item else { + panic!("no controls planned"); + }; + assert!(h.route.is_some()); + rows += 1; + } + drop(rd); + assert_eq!(rows, ROWS); + } + + /// Mirror-row refs and fences survive multi-batch planning re-based to + /// plan-global indices: control `row_idx` and truncate cursors index + /// the concatenation of carried batches + #[tokio::test(flavor = "current_thread")] + async fn mirror_rows_and_fences_rebase_globally() { + use crate::xact::spill::ToastChunk; + let tmp = tempfile::tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = descriptor(16605, INT4OID); + for (seq, lsn) in [(0u32, 100u64), (1, 102)] { + b.on_toast_chunk( + ToastChunk { + toast_relid: 16606, + value_id: 50, + chunk_seq: seq, + source_lsn: lsn, + blkno: 0, + offnum: 1 + seq as u16, + chunk_data: bytes::Bytes::from_static(b"abcd"), + }, + 1, + ) + .await + .unwrap(); + b.on_heap(heap(&rel, lsn + 1, ColumnValue::Int4(seq as i32))) + .await + .unwrap(); + } + b.on_schema_event( + 1, + 104, + SchemaEvent::Dropped { + oid: 16699, + rel_name: RelName::new("public", "other"), + }, + ); + let mut truncate = heap(&rel, 105, ColumnValue::Int4(0)); + truncate.decoded.op = HeapOp::Truncate; + truncate.decoded.new = None; + b.on_heap(truncate).await.unwrap(); + + let mut view = MapView { + routes: HashMap::from([(rel.rel_name.clone(), route())]), + dropped: HashSet::new(), + applied: Vec::new(), + }; + let resolver = ToastResolver::disabled(); + let mut planner = + Planner::create(tmp.path().join("1.plan"), 1 << 20, &mut view, &resolver).unwrap(); + let mut drain = b.drain_committed(1, 42, 0x2000, &[], true).await.unwrap(); + while let Some(batch) = drain.next_batch(1, usize::MAX, None).await.unwrap() { + let is_final = batch.is_final; + planner.plan_batch(batch).await.unwrap(); + if is_final { + break; + } + } + drain.finish().await.unwrap(); + let plan = planner.seal(0x2000, 42).unwrap(); + let total_rows: usize = plan.row_batches.iter().map(|rb| rb.len()).sum(); + assert_eq!(total_rows, 2, "both chunk births carried"); + assert_eq!(plan.heap_count, 3, "2 inserts + truncate"); + assert_eq!(plan.controls.len(), 1); + assert_eq!( + plan.controls[0].row_idx, 2, + "event fence re-based to plan-global rows" + ); + assert_eq!( + plan.truncate_rows, + vec![2], + "truncate fence re-based to plan-global rows" + ); + } + + /// Routed partial update fails the plan (no reconstruction path); the + /// same row plans cleanly as an unmapped discard, matching the spec's + /// "unmapped rows do not require validation for unreferenced columns" + #[tokio::test(flavor = "current_thread")] + async fn partial_update_fails_plan_only_when_routed() { + for (routed, expect_err) in [(true, true), (false, false)] { + let tmp = tempfile::tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = descriptor(16607, INT4OID); + let mut partial = heap(&rel, 100, ColumnValue::Int4(1)); + partial.decoded.op = HeapOp::Update; + partial.decoded.new.as_mut().unwrap().partial = true; + b.on_heap(partial).await.unwrap(); + + let mut view = MapView { + routes: if routed { + HashMap::from([(rel.rel_name.clone(), route())]) + } else { + HashMap::new() + }, + dropped: HashSet::new(), + applied: Vec::new(), + }; + let resolver = ToastResolver::disabled(); + let path = tmp.path().join("1.plan"); + let mut planner = Planner::create(path.clone(), 1 << 20, &mut view, &resolver).unwrap(); + 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 res = planner.plan_batch(batch).await; + drain.finish().await.unwrap(); + if expect_err { + assert!( + matches!(res, Err(PlanError::PartialUpdate { lsn: 100 })), + "{res:?}" + ); + drop(planner); + assert!(!path.exists(), "failed plan unlinks"); + } else { + res.unwrap(); + let plan = planner.seal(0x2000, 42).unwrap(); + assert_eq!(plan.heap_count, 1, "unmapped partial plans as discard"); + } + } + } + + /// Unmapped heaps skip the resolver entirely: the same unresolvable + /// pointer plans cleanly when the view discards the relation + #[tokio::test(flavor = "current_thread")] + async fn unmapped_heap_skips_detoast() { + let tmp = tempfile::tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = descriptor(16603, TEXTOID); + b.on_heap(heap( + &rel, + 100, + ColumnValue::ExternalToast(ToastPointer { + va_rawsize: 64, + va_extinfo: 60, + va_valueid: 9999, + va_toastrelid: 16604, + }), + )) + .await + .unwrap(); + + let mut view = MapView { + routes: HashMap::new(), + dropped: HashSet::new(), + applied: Vec::new(), + }; + let resolver = ToastResolver::disabled(); + let mut planner = + Planner::create(tmp.path().join("1.plan"), 1 << 20, &mut view, &resolver).unwrap(); + let mut drain = b.drain_committed(1, 42, 0x2000, &[], false).await.unwrap(); + while let Some(batch) = drain.next_batch(8, usize::MAX, None).await.unwrap() { + let is_final = batch.is_final; + planner.plan_batch(batch).await.unwrap(); + if is_final { + break; + } + } + drain.finish().await.unwrap(); + let plan = planner.seal(0x2000, 42).unwrap(); + assert_eq!(plan.heap_count, 1); + let mut rd = plan.replay().unwrap(); + let Some(PlanItem::Heap(h)) = rd.next_item().unwrap() else { + panic!("expected heap"); + }; + assert!(h.route.is_none(), "unmapped discard planned as such"); + } +} diff --git a/src/emit/pipeline/reorder.rs b/src/emit/pipeline/reorder.rs index f55248a9..eecd9fdf 100644 --- a/src/emit/pipeline/reorder.rs +++ b/src/emit/pipeline/reorder.rs @@ -23,44 +23,42 @@ use std::collections::{HashMap, HashSet}; use tokio::sync::{Mutex, mpsc, oneshot, watch}; use walrus::pg::walparser::RmId; -use crate::catalog::shadow_catalog::{CatalogError, ShadowCatalog}; -use crate::decode::decoder_sink::DecoderSinkError; -use crate::decode::heap_decoder::{DecodedHeap, HeapOp}; +use crate::catalog::desc_log::DescriptorLog; +use crate::catalog::shadow_catalog::ShadowCatalog; +use crate::decode::heap_decoder::{DescribedHeap, HeapOp}; use crate::emit::ch_ddl::DdlApplicator; use crate::emit::ch_emitter::EmitterStats; use crate::record::{Record, RecordSink, SinkError}; -use crate::schema::{RelName, SchemaEvent, SchemaEventRx}; +use crate::schema::{RelDescriptor, RelName, SchemaEvent}; use tracing::Instrument; use crate::decode::wal_xact::{ XLOG_XACT_ABORT, XLOG_XACT_ABORT_PREPARED, XLOG_XACT_ASSIGNMENT, XLOG_XACT_COMMIT, - XLOG_XACT_COMMIT_PREPARED, XLOG_XACT_OPMASK, XactCommitPayload, parse_xact_assignment, - parse_xact_payload, + XLOG_XACT_COMMIT_PREPARED, XLOG_XACT_OPMASK, parse_xact_assignment, parse_xact_payload, }; use crate::ops::trace::TxnSpanRegistry; -use crate::xact::xact_buffer::{ - ChunkGeneration, DrainEntry, DrainedBatch, SubxactTracker, ToastRowBatch, WalkStep, XactBuffer, - drain_pending_schema_events, -}; +use crate::xact::xact_buffer::{DrainEntry, SubxactTracker, XactBuffer}; use crate::config::{ConfigResolver, ResolvedConfig}; use crate::emit::pipeline::Fatal; use crate::emit::pipeline::ack::AckHandle; use crate::emit::pipeline::batcher::BatcherMsg; use crate::emit::pipeline::decode::DecodeJob; +use crate::emit::pipeline::plan_spool::{PlanItem, SealedPlan}; +use crate::emit::pipeline::planner::{PlanRouteView, Planner, drain_reason}; +use crate::emit::route::{RouteSnapshot, RoutedHeap}; +use crate::mapping::{MappingHandle, MappingSnapshot, TableMapping}; use crate::runtime_config::{ConfigEvent, TableRow}; use crate::toast::ToastResolver; use crate::toast::toast_retire::RetireLedger; pub struct ReorderSink { buffer: Arc>, + /// Interval-scoped descriptor oracle: stash resolution + truncate + log: Arc, + /// Opt-in dispatch still resolves by name against live shadow catalog: Arc>, subxact_tracker: Arc>, - schema_events: Option, - /// Armed by the decoder at pg_class heap_delete records, consumed - /// only at the arming xact's own commit (see - /// [`PendingSweeps`](crate::filter::catalog_tracker::PendingSweeps)) - pending_sweeps: Option, /// `None` on the metrics-only (null-tail) configuration: schema events /// and truncates are observed, never applied to CH applicator: Option, @@ -115,16 +113,33 @@ pub struct ReorderSink { /// the shadow. Retried each commit until it resolves, then created + /// backfilled (moves to `applied_opt_ins`). pending_opt_ins: HashMap, + /// Shared routing map, snapshotted into `route_mapping` at route-state + /// resets (this coordinator's own event applies are the fenced writers). + mapping: MappingHandle, + /// Boot-only delete-retention policy, frozen into route snapshots. + soft_delete: bool, + /// Byte cap per transaction plan spool file + plan_disk_max: u64, + /// Plan spool directory (the xact spill dir), cached at spawn so the + /// per-commit path needs no buffer lock + plan_dir: std::path::PathBuf, + /// Mapping version frozen with the memo reset: one transaction plans + /// against one mapping version, a concurrent republish (SIGHUP / + /// control-socket, no WAL position) can't split its rows. In-walk event + /// applies re-snapshot so trailing heaps see the event's own effect. + route_mapping: Option, + /// Resolved-config snapshot taken with the memo reset; every override in + /// one interval comes from one snapshot, never a mid-interval republish. + route_config: Option>, } impl ReorderSink { #[allow(clippy::too_many_arguments)] pub fn new( buffer: Arc>, + log: Arc, catalog: Arc>, subxact_tracker: Arc>, - schema_events: Option, - pending_sweeps: Option, applicator: Option, ack: AckHandle, jobs_tx: async_channel::Sender, @@ -137,28 +152,31 @@ impl ReorderSink { span_registry: Option, batch_rows: usize, batch_bytes: usize, + plan_disk_max: u64, + plan_dir: std::path::PathBuf, budget: Option, retires: RetireLedger, resume_floor: Arc, + mapping: MappingHandle, + soft_delete: bool, ) -> Self { - let reload_rx = config_resolver.as_ref().map(|r| r.subscribe()); - let applied_opt_ins = reload_rx - .as_ref() - .map(|rx| { - rx.borrow() - .table_opt_ins - .iter() - .filter(|(_, row)| row.replicate == Some(true)) - .map(|(rel, _)| rel.clone()) - .collect() - }) - .unwrap_or_default(); + // subscribe() marks the current value seen, so a `ctl reload` + // racing pipeline spawn would stay invisible to has_changed — + // and seeding the applied set from that raced value would record + // its opt-ins as done without ever applying them. Start empty and + // force the first commit to diff from scratch: re-applying + // boot-seeded opt-ins is the designed-idempotent restart path + // (CH table persists, backfill ledger dedups) + let reload_rx = config_resolver.as_ref().map(|r| { + let mut rx = r.subscribe(); + rx.mark_changed(); + rx + }); Self { buffer, + log, catalog, subxact_tracker, - schema_events, - pending_sweeps, applicator, ack, jobs_tx, @@ -171,16 +189,33 @@ impl ReorderSink { next_seq: 0, batch_rows, batch_bytes, + plan_disk_max, + plan_dir, budget, span_registry, retires, resume_floor, reload_rx, - applied_opt_ins, + applied_opt_ins: HashSet::new(), pending_opt_ins: HashMap::new(), + mapping, + soft_delete, + + route_mapping: None, + route_config: None, } } + /// Route-point steps 1–2 close out here: preceding schema/config state is + /// applied, so drop memoized routes and freeze mapping + resolved-config + /// versions for the next interval. Per commit this is the + /// whole-transaction snapshot; a non-WAL-positioned republish landing + /// mid-plan can't reroute rows already planned or split the transaction. + async fn reset_route_state(&mut self) { + self.route_mapping = Some(self.mapping.read().await.clone()); + self.route_config = self.reload_rx.as_ref().map(|rx| rx.borrow().clone()); + } + /// Apply a live config reload's table opt-in/opt-out diff at a commit /// barrier (`opt_in_lsn = commit_lsn`). Base config (mappings/budgets/CH /// connection) already republished onto the watch; here we do the part that @@ -229,6 +264,12 @@ impl ReorderSink { self.pending_opt_ins.insert(rel, row); } } + tracing::info!( + target: "walshadow::config", + pending = self.pending_opt_ins.len(), + applied = self.applied_opt_ins.len(), + "reload diff applied", + ); } // Each commit, apply any pending opt-in the shadow catalog can now // resolve — a table created just before `select` races the CREATE's @@ -256,6 +297,11 @@ impl ReorderSink { .map_err(|e| SinkError::Other(format!("opt-in descriptor lookup: {e}")))? .is_some(); if !known { + tracing::debug!( + target: "walshadow::config", + qname = %rel, + "opt-in retry: descriptor unknown", + ); continue; } crate::backfill::opt_in::apply_table_opt_in( @@ -339,64 +385,6 @@ impl ReorderSink { Ok(()) } - /// Drain pending DROP events (post-`sweep_dropped`) into the buffer keyed - /// on `(xid, source_lsn)`. Mirrors - /// `XactRecordSink::route_pending_schema_events`. - async fn route_pending_schema_events( - &mut self, - xid: u32, - source_lsn: u64, - ) -> Result<(), SinkError> { - let Some(rx) = self.schema_events.as_ref() else { - return Ok(()); - }; - let pending = drain_pending_schema_events(rx); - if pending.is_empty() { - return Ok(()); - } - let mut buf = self.buffer.lock().await; - for ev in pending { - buf.on_schema_event(xid, source_lsn, ev); - } - Ok(()) - } - - /// Poll-based DROP discovery, run only at the commit of an xact that - /// wrote pg_class heap_delete (ADD COLUMN / VACUUM noise never arms) - /// so the replay gate makes the drop visible in shadow. Same as - /// `XactRecordSink`'s commit branch. - async fn maybe_sweep_dropped( - &mut self, - xid: u32, - payload: &XactCommitPayload, - source_lsn: u64, - ) -> Result<(), SinkError> { - if self.schema_events.is_none() { - return Ok(()); - } - let Some(pending) = &self.pending_sweeps else { - return Ok(()); - }; - if !pending.disarm(xid, payload.twophase_xid, &payload.subxacts) { - return Ok(()); - } - let dropped = { - let mut cat = self.catalog.lock().await; - if source_lsn > 0 { - cat.wait_for_replay(source_lsn) - .await - .map_err(|e| SinkError::from(DecoderSinkError::from(e)))?; - } - cat.sweep_dropped() - .await - .map_err(|e| SinkError::from(DecoderSinkError::from(e)))? - }; - if dropped > 0 { - self.route_pending_schema_events(xid, source_lsn).await?; - } - Ok(()) - } - // Helpers take `&mut self` so the borrow across awaits is `&mut Self` // (Send): owned `DdlApplicator`/`AsyncClient` is Send but not Sync, so a // shared `&Self` across an await wouldn't be Send. @@ -451,34 +439,6 @@ impl ReorderSink { self.wait_all_durable().await } - /// Dispatch accumulated barrier data rows as their own seq. No-op when - /// empty. Always a non-final slice: the commit's trailing rows=0 marker - /// carries the LSN publication, so a durable segment can't claim the - /// whole commit while later segments are in flight. - async fn dispatch_segment( - &mut self, - pending: &mut Vec, - commit_ts: i64, - commit_lsn: u64, - chunks: &[Arc], - permit: &Option>, - ) -> Result<(), SinkError> { - if pending.is_empty() { - return Ok(()); - } - let seq = self.alloc_seq(); - self.ack.register_partial(seq, commit_lsn); - let job = DecodeJob { - seq, - commit_ts, - commit_lsn, - heaps: std::mem::take(pending), - chunks: chunks.to_vec(), - permit: permit.clone(), - }; - self.dispatch_job(job).await - } - async fn apply_event(&mut self, event: &SchemaEvent, commit_lsn: u64) -> Result<(), SinkError> { let Some(applicator) = self.applicator.as_mut() else { return Ok(()); @@ -511,6 +471,27 @@ impl ReorderSink { Ok(()) } + /// Boot Added pass: every relation `Present` in the descriptor log at + /// resume gets an `Added` apply (idempotent `CREATE TABLE IF NOT + /// EXISTS` + forward-declaration materialise). Runs pre-pump every + /// boot, like [`Self::flush_due_retires`] — brownfield auto-create + /// tables exist at attach instead of first write, and newly enabled + /// auto-create/mapping picks up existing rels without log mutation. + pub async fn apply_boot_events( + &mut self, + descs: Vec>, + resume_lsn: u64, + ) -> Result<(), SinkError> { + for desc in descs { + if desc.kind == 't' { + continue; + } + self.apply_event(&SchemaEvent::Added { desc }, resume_lsn) + .await?; + } + Ok(()) + } + /// Retire below persisted resolved floor /// /// Restart resumes at the floor, DROP lock excludes later referrers. @@ -571,108 +552,178 @@ impl ReorderSink { } } - async fn apply_truncate(&mut self, heap: &DecodedHeap) -> Result<(), SinkError> { + async fn apply_truncate(&mut self, heap: &DescribedHeap) -> Result<(), SinkError> { let Some(applicator) = self.applicator.as_mut() else { return Ok(()); }; - let rel = match crate::catalog::shadow_catalog::resolve_at( - &self.catalog, - heap.rfn, - heap.source_lsn, - ) - .await - { - Ok(r) => r, - Err(CatalogError::ForeignDatabase(_)) => return Ok(()), - Err(e) => return Err(SinkError::from(DecoderSinkError::from(e))), - }; + // Attached at truncate fan-out (record time = pre-capture Present), + // so the rotation's drain-time Retired answer no longer needs a + // predecessor walk here + let rel = &heap.descriptor; applicator .truncate(&rel.rel_name) .await .map_err(|e| SinkError::Other(format!("ch truncate: {e}")))?; self.stats.truncates_emitted.fetch_add(1, Ordering::Relaxed); - // PG swaps TOAST relfilenode without listing it in `xl_heap_truncate` - if self.resolver.stores_chunks() { - let toast = { - let mut cat = self.catalog.lock().await; - cat.toast_descriptor_for(rel.oid) - .await - .map_err(|e| SinkError::from(DecoderSinkError::from(e)))? - }; - if let Some(t) = toast { - self.resolver - .truncate_mirror(t.oid) - .await - .map_err(|e| SinkError::Other(format!("toast mirror truncate: {e}")))?; - } + // PG swaps TOAST relfilenode without listing it in `xl_heap_truncate`; + // the descriptor carries the owner's toast oid + if self.resolver.stores_chunks() && rel.toast_oid != 0 { + self.resolver + .truncate_mirror(rel.toast_oid) + .await + .map_err(|e| SinkError::Other(format!("toast mirror truncate: {e}")))?; } Ok(()) } - /// Bounded just-in-time materialization from the batch's body spool - async fn put_rows_to( + /// Materialize plan mirror rows `[cursor..end)` just in time; global + /// indices span the plan's carried row batches in order + async fn put_plan_rows( &mut self, - rows: &ToastRowBatch, + plan: &SealedPlan, cursor: &mut usize, end: usize, ) -> Result<(), SinkError> { - if end > *cursor { - self.resolver - .put_row_refs(rows.spool(), &rows[*cursor..end]) - .await - .map_err(|e| SinkError::Other(format!("toast store put: {e}")))?; - *cursor = end; + let mut base = 0usize; + for rb in &plan.row_batches { + let (lo, hi) = ((*cursor).max(base), end.min(base + rb.len())); + if lo < hi { + self.resolver + .put_row_refs(rb.spool(), &rb[lo - base..hi - base]) + .await + .map_err(|e| SinkError::Other(format!("toast store put: {e}")))?; + } + base += rb.len(); } + *cursor = (*cursor).max(end); Ok(()) } - /// Fence each apply after preceding data; step order owned by - /// [`DrainedBatch::into_walk`] - async fn run_barrier_batch( + /// Dispatch accumulated planned heaps as one seq under a fresh + /// admission permit. Chunks ride empty: values detoasted at planning. + /// `publish` marks the commit's final data segment so its seq carries + /// the LSN publication (no trailing marker needed) + async fn dispatch_planned( &mut self, - batch: DrainedBatch, + pending: &mut Vec, + pending_bytes: &mut usize, commit_ts: i64, commit_lsn: u64, - permit: Option>, + publish: bool, ) -> Result<(), SinkError> { - let walk = batch.into_walk(); - let mut pending: Vec = Vec::new(); + if pending.is_empty() { + return Ok(()); + } + let heaps = std::mem::take(pending); + let bytes = std::mem::take(pending_bytes); + let permit = match &self.budget { + Some(b) => Some(Arc::new(b.admit(bytes).await)), + None => None, + }; + let seq = self.alloc_seq(); + if publish { + self.ack.register(seq, commit_lsn); + } else { + self.ack.register_partial(seq, commit_lsn); + } + let job = DecodeJob { + seq, + commit_ts, + commit_lsn, + heaps, + chunks: Vec::new(), + permit, + }; + self.dispatch_job(job).await + } + + /// Replay one sealed plan through the existing barrier ordering. Routes + /// ride the plan — nothing re-resolves. Heap segments slice at the + /// batch budget; controls fence then apply their real side effects at + /// their pinned positions; mirror rows put just in time; truncates + /// fence per their carried cursor. The final data segment publishes the + /// commit LSN when nothing follows it; otherwise the caller's trailing + /// rows=0 marker does. Returns dispatched rows + whether it published + pub async fn execute_plan(&mut self, plan: &SealedPlan) -> Result<(u64, bool), SinkError> { + let (commit_ts, commit_lsn) = (plan.commit_ts, plan.commit_lsn); + // Mem-resident plans hold the bytes validated at write; file-backed + // plans re-read from disk, checksum-verify fully before the first + // side effect so corruption fails the whole transaction + if plan.path().is_some() { + plan.verify() + .map_err(|e| SinkError::Other(format!("plan verify: {e}")))?; + } + let mut rd = plan + .replay() + .map_err(|e| SinkError::Other(format!("plan replay: {e}")))?; + let mut pending: Vec = Vec::new(); + let mut pending_bytes = 0usize; let mut rows_cursor = 0usize; - for step in walk.steps { - match step { - WalkStep::Rows { upto } => { - self.put_rows_to(&walk.new_rows, &mut rows_cursor, upto) + let mut trunc = plan.truncate_rows.iter().copied(); + let total_rows: usize = plan.row_batches.iter().map(|rb| rb.len()).sum(); + let mut rows_total = 0u64; + while let Some(item) = rd + .next_item() + .map_err(|e| SinkError::Other(format!("plan replay: {e}")))? + { + match item { + PlanItem::Control(c) => { + self.put_plan_rows(plan, &mut rows_cursor, c.row_idx) .await?; - } - WalkStep::Event(entry) => { - self.dispatch_segment( + self.dispatch_planned( &mut pending, + &mut pending_bytes, commit_ts, commit_lsn, - &walk.chunks, - &permit, + false, ) .await?; self.barrier_fence().await?; - self.apply_drain_entry(&entry, commit_lsn).await?; + self.apply_drain_entry(&c.event, commit_lsn).await?; } - WalkStep::Truncate(heap) => { - self.dispatch_segment( + PlanItem::Heap(h) if matches!(h.described.decoded.op, HeapOp::Truncate) => { + let upto = trunc.next().unwrap_or(rows_cursor); + self.put_plan_rows(plan, &mut rows_cursor, upto).await?; + self.dispatch_planned( &mut pending, + &mut pending_bytes, commit_ts, commit_lsn, - &walk.chunks, - &permit, + false, ) .await?; self.barrier_fence().await?; - self.apply_truncate(&heap).await?; + self.apply_truncate(&h.described).await?; + } + PlanItem::Heap(h) => { + rows_total += 1; + pending_bytes += h.described.approx_bytes(); + pending.push(h); + if pending.len() >= self.batch_rows || pending_bytes >= self.batch_bytes { + self.dispatch_planned( + &mut pending, + &mut pending_bytes, + commit_ts, + commit_lsn, + false, + ) + .await?; + } } - WalkStep::Heap(heap) => pending.push(heap), } } - self.dispatch_segment(&mut pending, commit_ts, commit_lsn, &walk.chunks, &permit) - .await + self.put_plan_rows(plan, &mut rows_cursor, total_rows) + .await?; + let publish = !pending.is_empty(); + self.dispatch_planned( + &mut pending, + &mut pending_bytes, + commit_ts, + commit_lsn, + publish, + ) + .await?; + Ok((rows_total, publish)) } async fn on_commit( @@ -682,7 +733,12 @@ impl ReorderSink { record: &Record<'_>, ) -> Result<(), SinkError> { self.flush_due_retires().await?; - let payload = parse_xact_payload(info, &record.parsed.main_data); + let payload = parse_xact_payload(info, &record.parsed.main_data, record.page_magic) + .unwrap_or_default(); + // COMMIT PREPARED: header xid is the finishing backend's (0-ish), + // the buffered work lives under the prepared xid — drain there, or + // capture-keyed events would never leave the buffer + let xid = payload.twophase_xid.unwrap_or(xid); // Parent for this commit's spans; held until on_commit returns so it // outlives the prune below. No-op span when tracing off/unsampled. let txn = self @@ -690,21 +746,16 @@ impl ReorderSink { .as_ref() .and_then(|r| r.txn_span(xid)) .unwrap_or_else(tracing::Span::none); - self.maybe_sweep_dropped(xid, &payload, record.source_lsn) - .await?; crate::xact::xact_buffer::resolve_stash( &self.buffer, - &self.catalog, + &self.log, xid, &payload.subxacts, - record.source_lsn, + record.next_lsn, self.stats.clone(), ) .await .map_err(SinkError::from)?; - // Resolution can surface Added events for newly visible rels - self.route_pending_schema_events(xid, record.source_lsn) - .await?; let drain_span = trace_span!( !txn.is_none(), parent: &txn, @@ -745,95 +796,95 @@ impl ReorderSink { r.prune(&xids); } - // Pull bounded slices from the lazy merge: the decode pool works one - // while the next loads, so a spilled xact never rematerializes whole. + // Plan the whole transaction side-effect-free, then execute the + // sealed plan: every input-derived failure surfaces before the first + // side effect. A planning error abandons the plan file (writer drop + // unlinks) and the transaction emits nothing. let commit_ts = drain.commit_ts; let commit_lsn = drain.commit_lsn; // Apply any pending live-reload opt-in/opt-out diff before this commit's // rows so newly-selected tables are in scope + created for it. self.maybe_apply_reload(commit_lsn).await?; + // One route state per transaction: a mid-commit config republish + // can't split this xact's rows across two route versions. In-walk + // catalog events fold into the plan-time view, not shared state. + self.reset_route_state().await; let mut rows_total: u64 = 0; - // Set once a slice's seq registered as publishing (final data slice); - // otherwise the trailing rows=0 marker publishes. let mut published = false; - loop { - let Some(batch) = drain - .next_batch(self.batch_rows, self.batch_bytes, self.budget.as_ref()) - .instrument(drain_span.clone()) - .await - .map_err(SinkError::from)? - else { - break; - }; - rows_total += batch.heaps.len() as u64; - // Admission before dispatch keeps backpressure here. Sealed - // generations already carry permits acquired by `next_batch`; - // slice permit covers decoded heap bytes + row metadata - let permit = match &self.budget { - Some(b) => { - let bytes = batch.heaps.iter().map(|h| h.approx_bytes()).sum::() - + batch.new_rows.resident_bytes(); - Some(Arc::new(b.admit(bytes).await)) + if drain.had_states { + let plan_path = self.plan_dir.join(format!("xact-{xid}-{commit_lsn}.plan")); + let plan = { + let mut view = ReorderRouteView::new( + self.route_mapping.clone(), + self.route_config.clone(), + self.soft_delete, + self.applicator.as_mut(), + self.stats.clone(), + ); + let resolver = self.resolver.clone(); + let (batch_rows, batch_bytes) = (self.batch_rows, self.batch_bytes); + let budget = self.budget.clone(); + let stats = self.stats.clone(); + let mut planner = + Planner::create(plan_path, self.plan_disk_max, &mut view, &resolver).map_err( + |e| { + bump_plan_failure(&stats, e.reason()); + SinkError::Other(format!("plan open: {e}")) + }, + )?; + loop { + let Some(batch) = drain + .next_batch(batch_rows, batch_bytes, budget.as_ref()) + .instrument(drain_span.clone()) + .await + .map_err(|e| { + bump_plan_failure(&stats, drain_reason(&e)); + SinkError::from(e) + })? + else { + break; + }; + let is_final = batch.is_final; + planner.plan_batch(batch).await.map_err(|e| { + bump_plan_failure(&stats, e.reason()); + SinkError::Other(format!("plan: {e}")) + })?; + if is_final { + break; + } } - None => None, + planner.seal(commit_lsn, commit_ts).map_err(|e| { + bump_plan_failure(&stats, e.reason()); + SinkError::Other(format!("plan seal: {e}")) + })? }; - let is_barrier = !batch.ordered_events.is_empty() - || batch.heaps.iter().any(|h| matches!(h.op, HeapOp::Truncate)); - let is_final = batch.is_final; - // Store rows must precede publishing marker; refs materialize - // just in time per sealed slice - if !is_barrier && !batch.new_rows.is_empty() { - self.resolver - .put_row_refs(batch.new_rows.spool(), &batch.new_rows) - .await - .map_err(|e| SinkError::Other(format!("toast store put: {e}")))?; - } - if is_barrier { - self.run_barrier_batch(batch, commit_ts, commit_lsn, permit) - .instrument(trace_span!( - !txn.is_none(), - parent: &txn, - "commit.barrier", - )) - .await?; - } else if !batch.heaps.is_empty() { - let seq = self.alloc_seq(); - if is_final { - self.ack.register(seq, commit_lsn); - published = true; - } else { - self.ack.register_partial(seq, commit_lsn); - } - let job = DecodeJob { - seq, - commit_ts, - commit_lsn, - heaps: batch.heaps, - chunks: batch.chunks, - permit, - }; - self.dispatch_job(job) - .instrument(trace_span!( - !txn.is_none(), - parent: &txn, - "dispatch", - seq = seq, - )) - .await?; - } - if is_final { - break; - } + self.stats + .plan_rows + .fetch_add(plan.routed_count, Ordering::Relaxed); + let plan_bytes = if plan.path().is_some() { + &self.stats.plan_bytes_file + } else { + &self.stats.plan_bytes_mem + }; + plan_bytes.fetch_add(plan.size_bytes, Ordering::Relaxed); + (rows_total, published) = self + .execute_plan(&plan) + .instrument(trace_span!( + !txn.is_none(), + parent: &txn, + "commit.execute", + )) + .await?; } - // Unlink spill files now that every slice dispatched; an error above + // Unlink spill files now that every segment dispatched; an error above // drops the drain instead, leaving files for inspection. drain.finish().await.map_err(SinkError::from)?; txn.record("rows", rows_total); txn.record("outcome", "committed"); if !published { - // rows=0 marker: publishes commit_lsn once every earlier slice - // is durable. Covers empty / read-only commits and barrier - // slices (whose segments all register partial). + // rows=0 marker: publishes commit_lsn once every earlier partial + // segment is durable. Covers empty / read-only commits and plans + // whose tail is a control or truncate. let seq = self.alloc_seq(); self.ack.register(seq, commit_lsn); self.ack.placed(seq, 0); @@ -844,11 +895,10 @@ impl ReorderSink { /// ABORT: drop the buffer, emit a rows=0 seq through the gate (never a /// direct ack bump). async fn on_abort(&mut self, xid: u32, info: u8, record: &Record<'_>) -> Result<(), SinkError> { - let payload = parse_xact_payload(info, &record.parsed.main_data); - // Rolled-back pg_class heap_delete resurrects the row; drop the arm - if let Some(pending) = &self.pending_sweeps { - pending.disarm(xid, payload.twophase_xid, &payload.subxacts); - } + let payload = parse_xact_payload(info, &record.parsed.main_data, record.page_magic) + .unwrap_or_default(); + // ABORT PREPARED: buffered state keys off the prepared xid + let xid = payload.twophase_xid.unwrap_or(xid); let seq = self.alloc_seq(); self.ack.register(seq, record.source_lsn); { @@ -863,6 +913,117 @@ impl ReorderSink { } } +/// Route a plan-failure reason label onto its counter +fn bump_plan_failure(stats: &EmitterStats, reason: &'static str) { + let counter = match reason { + "spool" => &stats.plan_failures_spool, + "fail_closed_image_only" => &stats.plan_failures_fail_closed_image_only, + "fail_closed_malformed" => &stats.plan_failures_fail_closed_malformed, + "fail_closed_unsupported_op" => &stats.plan_failures_fail_closed_unsupported_op, + "stash_ambiguous" => &stats.plan_failures_stash_ambiguous, + "incomplete_toast" => &stats.plan_failures_incomplete_toast, + "missing_stash_resolution" => &stats.plan_failures_missing_stash_resolution, + "detoast" => &stats.plan_failures_detoast, + "partial_update" => &stats.plan_failures_partial_update, + "view" => &stats.plan_failures_view, + _ => &stats.plan_failures_drain, + }; + counter.fetch_add(1, Ordering::Relaxed); +} + +/// Plan-time route state for one transaction: frozen mapping + config +/// versions plus a local fold of in-walk catalog entries. The applicator +/// predicts what executing each event will leave in the routing map +/// ([`DdlApplicator::predict_route_mapping`]) so post-event rows plan under +/// post-event routes while the real side effects wait for the executor. +/// Config-table events are not folded: a same-xact config write followed +/// by rows plans under the frozen version (whole-transaction granularity; +/// the in-xact interval refinement lands with the config fold) +pub struct ReorderRouteView<'a> { + mapping: Option, + config: Option>, + /// Catalog fold above `mapping`; `None` value = locally dropped + overlay: HashMap>, + memo: HashMap>>, + soft_delete: bool, + applicator: Option<&'a mut DdlApplicator>, + stats: Arc, +} + +impl<'a> ReorderRouteView<'a> { + pub fn new( + mapping: Option, + config: Option>, + soft_delete: bool, + applicator: Option<&'a mut DdlApplicator>, + stats: Arc, + ) -> Self { + Self { + mapping, + config, + overlay: HashMap::new(), + memo: HashMap::new(), + soft_delete, + applicator, + stats, + } + } +} + +impl PlanRouteView for ReorderRouteView<'_> { + fn route_for(&mut self, heap: &DescribedHeap) -> Option> { + let rel_name = &heap.descriptor.rel_name; + if let Some(r) = self.memo.get(rel_name) { + return r.clone(); + } + let mapped = match self.overlay.get(rel_name) { + Some(o) => o.clone(), + None => self.mapping.as_ref().and_then(|m| m.get(rel_name)).cloned(), + }; + let route = mapped.map(|m| { + let overrides = self + .config + .as_ref() + .and_then(|rc| rc.columns.get(rel_name)) + .cloned() + .map(Arc::new) + .unwrap_or_default(); + RouteSnapshot::freeze(Arc::new(m), overrides, self.soft_delete) + }); + let result = if route.is_none() { + self.stats + .unsupported_relations + .fetch_add(1, Ordering::Relaxed); + &self.stats.route_snapshots_unmapped + } else { + &self.stats.route_snapshots_mapped + }; + result.fetch_add(1, Ordering::Relaxed); + self.memo.insert(rel_name.clone(), route.clone()); + route + } + + async fn apply(&mut self, entry: &DrainEntry) -> Result<(), String> { + let DrainEntry::Catalog(ev) = entry else { + // Config: frozen-version planning (doc above); ToastBarrier: + // no route effect + return Ok(()); + }; + let Some(app) = self.applicator.as_deref_mut() else { + return Ok(()); + }; + if let Some((rel, m)) = app + .predict_route_mapping(ev) + .await + .map_err(|e| e.to_string())? + { + self.memo.remove(&rel); + self.overlay.insert(rel, m); + } + Ok(()) + } +} + impl RecordSink for ReorderSink { fn on_record<'a>( &'a mut self, @@ -901,15 +1062,17 @@ impl RecordSink for ReorderSink { Box::pin(async move { // Trailing non-commit WAL only when no xact buffered; collector // also requires every registered seq done before advancing. - let active = self.buffer.lock().await.stats().xacts_active; - if active == 0 { + { + let mut buf = self.buffer.lock().await; + if buf.stats().xacts_active != 0 { + return Ok(()); + } self.ack.trailing(lsn); - self.buffer.lock().await.advance_idle(lsn); - // Quiescent source never re-enters on_commit; retire due - // drops here so the flush doesn't wait for a later commit - self.flush_due_retires().await?; + buf.advance_idle(lsn); } - Ok(()) + // Quiescent source never re-enters on_commit; retire due drops + // here so the flush doesn't wait for a later commit + self.flush_due_retires().await }) } } diff --git a/src/emit/route.rs b/src/emit/route.rs new file mode 100644 index 00000000..d881ce71 --- /dev/null +++ b/src/emit/route.rs @@ -0,0 +1,63 @@ +//! Route envelopes — frozen routing/encoding state attached to rows. +//! +//! [`RouteSnapshot`] freezes the encoder-plan inputs a relation resolved to +//! over one WAL interval: destination mapping, `config_column` overrides, +//! encoding policy. Rows carry the snapshot to the batcher so a mapping or +//! config change never reinterprets rows already routed. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::decode::heap_decoder::DescribedHeap; +use crate::mapping::{TableMapping, TableTarget}; + +/// `config_column` overlay slice for one relation: source attname → CH type +pub type ColumnOverrides = HashMap; + +/// Encoder-plan inputs beyond mapping + overrides. Alloc-free (no parsed +/// type ASTs) so snapshots can serialize and dedup by content +#[derive(Debug)] +pub struct RowEncodingSnapshot { + pub destination: TableTarget, + /// CH-side delete retention policy (`_is_deleted` stays queryable); + /// boot-only knob, snapshotted so execution never reads live config + pub soft_delete: bool, +} + +/// Frozen route for one relation over one WAL interval +#[derive(Debug)] +pub struct RouteSnapshot { + pub mapping: Arc, + /// Overlay slice frozen with the route, consumed at batcher plan build. + /// Empty when the overlay is off or names no columns for this relation + pub column_overrides: Arc, + pub encoding: Arc, +} + +impl RouteSnapshot { + /// Freeze encoder-plan inputs; destination derives from mapping target + pub fn freeze( + mapping: Arc, + column_overrides: Arc, + soft_delete: bool, + ) -> Arc { + let encoding = Arc::new(RowEncodingSnapshot { + destination: mapping.target.clone(), + soft_delete, + }); + Arc::new(Self { + mapping, + column_overrides, + encoding, + }) + } +} + +/// Described heap plus its resolved route. `route = None` means the relation +/// is deterministically unmapped at that interval — a normal counted discard, +/// distinct from a missing descriptor +#[derive(Debug)] +pub struct RoutedHeap { + pub described: DescribedHeap, + pub route: Option>, +} diff --git a/src/filter/catalog_tracker.rs b/src/filter/catalog_tracker.rs index d04b93bb..5be55fd6 100644 --- a/src/filter/catalog_tracker.rs +++ b/src/filter/catalog_tracker.rs @@ -18,42 +18,8 @@ //! 16384 before walshadow attached, so its `XLOG_RELMAP_UPDATE` sits //! in pre-attach WAL the bootstrap rule never sees. //! -//! Invalidation signal: [`observe`](CatalogTracker::observe) returns a -//! [`CatalogSignal`] verdict that rides the -//! [`Record`](crate::record::Record) to the decoder worker, where -//! [`BufferingDecoderSink`](crate::xact::xact_buffer::BufferingDecoderSink) -//! bumps the shared invalidation epoch at its own stream position. -//! [`ShadowCatalog`](crate::catalog::shadow_catalog::ShadowCatalog) shares the -//! atomic, acquire-loads at every relation lookup, and invalidates before -//! the cache check. -//! -//! Bumping here at observe time instead would be premature once -//! [`QueueingRecordSink`](crate::source::queueing_record_sink::QueueingRecordSink) -//! decouples the decoder from the pump: the tracker observes at pump -//! position while the decoder worker may still be thousands of records -//! behind. A worker lookup for a pre-DDL record then consumes the bump, -//! fetches from a shadow that hasn't replayed the DDL's commit, and -//! caches the pre-DDL descriptor as fresh — permanently, since no second -//! bump comes. Bumping when the DDL record itself passes the worker keeps -//! consumption in record order: any later lookup of the altered relation -//! is triggered by a record past the DDL's commit (AccessExclusive lock -//! excludes interleaved rows), so its replay gate guarantees a fresh -//! fetch. Out-of-band bumpers stay: mapping writes -//! (`crate::emit::ch_ddl::bump_mapping_epoch`) and SIGHUP mapping reload. -//! -//! DROP discovery cannot ride the same counter: a drop only becomes -//! visible to `sweep_dropped`'s pg_class probe once the *dropping xact's -//! commit* replays in shadow. An epoch consumed at whatever commit drains -//! first (any interleaved xact committing between the heap_delete and the -//! DROP's commit) sweeps at a replay position where the dying tuple is -//! still MVCC-alive, finds nothing, and swallows the signal — the Dropped -//! event is lost. [`PendingSweeps`] therefore keys arming by xid: -//! [`CatalogSignal::InvalidateSweep`] arms the writing xact at worker -//! position; commit sinks consume only at that xact's own commit, whose -//! replay gate guarantees the drop is visible. Aborts disarm. use std::collections::{HashMap, HashSet}; -use std::sync::Arc; use thiserror::Error; use tokio_postgres::Client; @@ -63,7 +29,6 @@ use walrus::pg::walparser::{RmId, XLogRecord}; use crate::filter::pg_class_decoder::{ DecodeOutcome, decode_pg_class_tuple, info_carries_new_tuple_heap, info_carries_new_tuple_heap2, }; -use crate::record::CatalogSignal; use crate::schema::FIRST_NORMAL_OBJECT_ID; /// XLOG_RELMAP_UPDATE info byte (`xl_info & XLR_RMGR_INFO_MASK`). @@ -75,44 +40,11 @@ const REL_MAP_FILE_SIZE: usize = 4 + 4 + MAX_MAPPINGS * 8 + 4; // magic + n + ma /// `pg_class.oid`, fixed PG catalog OID pub const PG_CLASS_OID: u32 = 1259; - -/// DROP-sweep arming keyed by xid. The decoder worker arms on -/// [`CatalogSignal::InvalidateSweep`] with the record's xact id; commit -/// sinks disarm at that xact's commit and only then run -/// `ShadowCatalog::sweep_dropped`, so the sweep's replay gate (commit -/// LSN of the dropping xact) guarantees the drop is MVCC-visible in -/// shadow. Aborts disarm without sweeping. Uncontended std mutex: armer -/// and consumer run on the same queueing worker. -#[derive(Debug, Clone, Default)] -pub struct PendingSweeps(Arc>>); - -impl PendingSweeps { - pub fn new() -> Self { - Self::default() - } - - pub fn arm(&self, xid: u32) { - self.0.lock().expect("pending sweeps poisoned").insert(xid); - } - - /// Remove every xid of a finished xact (top, prepared-xact id, - /// subxacts); true when any was armed. Commit acts on true, abort - /// discards the result. - pub fn disarm(&self, top: u32, twophase: Option, subxacts: &[u32]) -> bool { - let mut set = self.0.lock().expect("pending sweeps poisoned"); - if set.is_empty() { - return false; - } - let mut hit = set.remove(&top); - if let Some(x) = twophase { - hit |= set.remove(&x); - } - for x in subxacts { - hit |= set.remove(x); - } - hit - } -} +/// `pg_namespace.oid`; writes to it force capture-all (relcache invals +/// enumerate rels only for pg_class/pg_attribute/pg_index/pg_constraint +/// changes — PG `src/backend/utils/cache/inval.c` — while namespace rename +/// changes every embedded namespace text with zero per-relation invals) +pub const PG_NAMESPACE_OID: u32 = 2615; #[derive(Debug, Default)] pub struct CatalogTracker { @@ -123,6 +55,10 @@ pub struct CatalogTracker { /// `rel == PG_CLASS_OID` (mapped-catalog relfilenode == oid until /// first rewrite). pg_class_filenode: HashMap, + /// Current pg_namespace filenode per db; fallback `rel == + /// PG_NAMESPACE_OID`. Unmapped catalog: VACUUM FULL relocates it via + /// its own pg_class row, harvested below. + pg_namespace_filenode: HashMap, relmap_updates: u64, /// pg_class heap writes the decoder couldn't reconstruct (truncated / /// malformed `t_hoff`). OID-prefix-compressed records count in @@ -175,6 +111,25 @@ pub enum SeedError { Pg(#[from] tokio_postgres::Error), } +/// [`CatalogTracker::observe`] verdict: whether the record mutated a +/// tracked catalog plus, when block 0 decoded a user relation's pg_class +/// row, that oid — the filter's per-oid first-touch source for boundary +/// capture. +#[derive(Debug, Default, Clone, Copy)] +pub struct Observation { + pub catalog_write: bool, + pub pg_class_user_oid: Option, +} + +impl Observation { + fn write(catalog_write: bool) -> Self { + Self { + catalog_write, + pg_class_user_oid: None, + } + } +} + impl CatalogTracker { pub fn new() -> Self { Self::default() @@ -215,12 +170,12 @@ impl CatalogTracker { /// Returned verdict rides the record to the decoder worker, which /// bumps invalidation epochs at its own stream position (see module /// doc). - pub fn observe(&mut self, record: &XLogRecord) -> CatalogSignal { + pub fn observe(&mut self, record: &XLogRecord) -> Observation { let rm = record.header.resource_manager_id; let info_high = record.header.info & 0xF0; if rm == RmId::RelMap as u8 && info_high == XLOG_RELMAP_UPDATE { - return self.handle_relmap_update(record); + return Observation::write(self.handle_relmap_update(record)); } let heap_new_tuple = rm == RmId::Heap as u8 && info_carries_new_tuple_heap(info_high); @@ -237,22 +192,22 @@ impl CatalogTracker { let info_op = info_high & 0x70; if info_op == 0x10 { // HEAP_DELETE - return self.signal_pg_class_touch(record); + return Observation::write(self.signal_pg_class_touch(record)); } } - CatalogSignal::None + Observation::default() } /// Coarse-fire (no row decode) when a record hits the current /// pg_class filenode, for ops the harvest path skips (DELETE). The /// `InvalidateSweep` verdict arms the DROP sweep at the worker /// ([`PendingSweeps`], keyed by the record's xid). - fn signal_pg_class_touch(&mut self, record: &XLogRecord) -> CatalogSignal { + fn signal_pg_class_touch(&mut self, record: &XLogRecord) -> bool { if self.pg_class_block(record).is_none() { - return CatalogSignal::None; + return false; } self.invalidation_signals_sent += 1; - CatalogSignal::InvalidateSweep + true } /// First block's `(db_node, rel_node)` iff it targets the current @@ -269,15 +224,22 @@ impl CatalogTracker { /// Decode block 0 when `record` targets pg_class. PG registers the /// new tuple via `XLogRegisterBufData(0, ...)`; later block refs /// (heap_update's block 1 old page) carry no tuple, must not decode. - fn harvest_pg_class_blocks(&mut self, record: &XLogRecord) -> CatalogSignal { + fn harvest_pg_class_blocks(&mut self, record: &XLogRecord) -> Observation { let Some((db, _rel)) = self.pg_class_block(record) else { - return CatalogSignal::None; + return Observation::default(); }; + let mut user_oid = None; match decode_pg_class_tuple(record, 0) { DecodeOutcome::Decoded(row) => { self.pg_class_writes_decoded += 1; if row.oid != 0 && row.oid < FIRST_NORMAL_OBJECT_ID && row.relfilenode != 0 { self.nodes.insert((db, row.relfilenode)); + if row.oid == PG_NAMESPACE_OID { + self.pg_namespace_filenode.insert(db, row.relfilenode); + } + } + if row.oid >= FIRST_NORMAL_OBJECT_ID { + user_oid = Some(row.oid); } } DecodeOutcome::OidInPrefix => { @@ -294,7 +256,27 @@ impl CatalogTracker { // Coarse-fire regardless: over-invalidation is cheap (lazy // refetch), under-invalidation silently masks DDL. self.invalidation_signals_sent += 1; - CatalogSignal::Invalidate + Observation { + catalog_write: true, + pg_class_user_oid: user_oid, + } + } + + /// True when `(db, rel)` is pg_namespace's current heap — the + /// capture-all trigger set. + /// + /// pg_type stays out by choice, though its writes are equally + /// unenumerated by relcache invals: CREATE TABLE writes pg_type on every + /// run (composite + array rows), so triggering on it would fire + /// capture-all constantly and leave the enumerated path dead. The cost + /// is a stale `RelAttr.type_name` after `ALTER TYPE … RENAME`, which no + /// decode path reads — accepted, with remediation options in + /// `plans/future/catalog_capture_completeness.md` + pub fn is_capture_all_catalog(&self, db: u32, rel: u32) -> bool { + match self.pg_namespace_filenode.get(&db) { + Some(&fnum) => fnum == rel, + None => rel == PG_NAMESPACE_OID, + } } /// Falls back to `rel == PG_CLASS_OID` until a filenode is observed @@ -306,27 +288,27 @@ impl CatalogTracker { } } - fn handle_relmap_update(&mut self, record: &XLogRecord) -> CatalogSignal { + fn handle_relmap_update(&mut self, record: &XLogRecord) -> bool { self.relmap_updates += 1; let md = &record.main_data; // xl_relmap_update header: dbid(4) + tsid(4) + nbytes(4) = 12 if md.len() < 12 + REL_MAP_FILE_SIZE { - return CatalogSignal::None; + return false; } let dbid = u32::from_le_bytes(md[0..4].try_into().unwrap()); let _tsid = u32::from_le_bytes(md[4..8].try_into().unwrap()); let nbytes = i32::from_le_bytes(md[8..12].try_into().unwrap()) as usize; if nbytes != REL_MAP_FILE_SIZE { - return CatalogSignal::None; + return false; } let map = &md[12..12 + REL_MAP_FILE_SIZE]; let magic = i32::from_le_bytes(map[0..4].try_into().unwrap()); if magic != RELMAPPER_FILEMAGIC { - return CatalogSignal::None; + return false; } let num_mappings = i32::from_le_bytes(map[4..8].try_into().unwrap()) as usize; if num_mappings > MAX_MAPPINGS { - return CatalogSignal::None; + return false; } let mappings = &map[8..8 + MAX_MAPPINGS * 8]; for i in 0..num_mappings { @@ -341,7 +323,7 @@ impl CatalogTracker { } } self.invalidation_signals_sent += 1; - CatalogSignal::Invalidate + true } /// Query source `pg_class` for every catalog relation (oid < 16384). @@ -379,6 +361,9 @@ impl CatalogTracker { if catalog_oid == PG_CLASS_OID { self.pg_class_filenode.insert(db_node, filenode); } + if catalog_oid == PG_NAMESPACE_OID { + self.pg_namespace_filenode.insert(db_node, filenode); + } } self.seeded_from_source += added as u64; Ok(added) @@ -393,6 +378,37 @@ impl CatalogTracker { } } +/// Well-formed `XLOG_RELMAP_UPDATE` record, shared with filter-engine tests. +#[cfg(test)] +pub(crate) fn test_relmap_record(dbid: u32, mappings: &[(u32, u32)]) -> XLogRecord<'static> { + let mut data = Vec::new(); + data.extend_from_slice(&dbid.to_le_bytes()); + data.extend_from_slice(&1664u32.to_le_bytes()); // tsid pg_global + data.extend_from_slice(&(REL_MAP_FILE_SIZE as i32).to_le_bytes()); + data.extend_from_slice(&RELMAPPER_FILEMAGIC.to_le_bytes()); + data.extend_from_slice(&(mappings.len() as i32).to_le_bytes()); + for &(oid, fnum) in mappings { + data.extend_from_slice(&oid.to_le_bytes()); + data.extend_from_slice(&fnum.to_le_bytes()); + } + for _ in mappings.len()..MAX_MAPPINGS { + data.extend_from_slice(&[0u8; 8]); + } + data.extend_from_slice(&0u32.to_le_bytes()); // crc, ignored + + XLogRecord { + header: walrus::pg::walparser::XLogRecordHeader { + resource_manager_id: RmId::RelMap as u8, + info: XLOG_RELMAP_UPDATE, + total_record_length: 24 + data.len() as u32, + ..Default::default() + }, + main_data_len: data.len() as u32, + main_data: std::borrow::Cow::Owned(data), + ..Default::default() + } +} + #[cfg(test)] mod tests { use super::*; @@ -400,34 +416,7 @@ mod tests { BlockLocation, RelFileNode, XLogRecordBlock, XLogRecordBlockHeader, XLogRecordHeader, }; - fn relmap_record(dbid: u32, mappings: &[(u32, u32)]) -> XLogRecord<'static> { - let mut data = Vec::new(); - data.extend_from_slice(&dbid.to_le_bytes()); - data.extend_from_slice(&1664u32.to_le_bytes()); // tsid pg_global - data.extend_from_slice(&(REL_MAP_FILE_SIZE as i32).to_le_bytes()); - data.extend_from_slice(&RELMAPPER_FILEMAGIC.to_le_bytes()); - data.extend_from_slice(&(mappings.len() as i32).to_le_bytes()); - for &(oid, fnum) in mappings { - data.extend_from_slice(&oid.to_le_bytes()); - data.extend_from_slice(&fnum.to_le_bytes()); - } - for _ in mappings.len()..MAX_MAPPINGS { - data.extend_from_slice(&[0u8; 8]); - } - data.extend_from_slice(&0u32.to_le_bytes()); // crc, ignored - - XLogRecord { - header: XLogRecordHeader { - resource_manager_id: RmId::RelMap as u8, - info: XLOG_RELMAP_UPDATE, - total_record_length: 24 + data.len() as u32, - ..Default::default() - }, - main_data_len: data.len() as u32, - main_data: std::borrow::Cow::Owned(data), - ..Default::default() - } - } + use super::test_relmap_record as relmap_record; fn heap_block_record( rm: RmId, @@ -665,7 +654,7 @@ mod tests { fn observe_relmap_update_signals() { let mut t = CatalogTracker::new(); let v = t.observe(&relmap_record(5, &[(1259, 50000)])); - assert_eq!(v, CatalogSignal::Invalidate, "relmap update must signal"); + assert!(v.catalog_write, "relmap update must signal"); assert_eq!(t.invalidation_signals_sent, 1); } @@ -674,11 +663,7 @@ mod tests { let mut t = CatalogTracker::new(); let data = pg_class_block_data(2615, 30000); let v = t.observe(&heap_block_record(RmId::Heap, 0x00, 5, 1259, data)); - assert_eq!( - v, - CatalogSignal::Invalidate, - "decoded pg_class write must signal", - ); + assert!(v.catalog_write, "decoded pg_class write must signal"); assert_eq!(t.invalidation_signals_sent, 1); } @@ -696,9 +681,8 @@ mod tests { data, md, )); - assert_eq!( - v, - CatalogSignal::Invalidate, + assert!( + v.catalog_write, "oid_in_prefix is still a catalog mutation — must signal", ); assert_eq!(t.invalidation_signals_sent, 1); @@ -709,7 +693,7 @@ mod tests { let mut t = CatalogTracker::new(); // Undecoded but still touched pg_class: coarse signal, cache drops let v = t.observe(&heap_block_record(RmId::Heap, 0x00, 5, 1259, vec![])); - assert_eq!(v, CatalogSignal::Invalidate); + assert!(v.catalog_write); assert_eq!(t.invalidation_signals_sent, 1); assert_eq!(t.pg_class_writes_undecoded, 1); } @@ -719,35 +703,32 @@ mod tests { // Verdict rides the record; the decoder worker bumps epochs off it // at its own stream position let mut t = CatalogTracker::new(); - assert_eq!( - t.observe(&relmap_record(5, &[(1259, 50000)])), - CatalogSignal::Invalidate, - ); + assert!(t.observe(&relmap_record(5, &[(1259, 50000)])).catalog_write); let data = pg_class_block_data(2615, 30000); - assert_eq!( - t.observe(&heap_block_record(RmId::Heap, 0x00, 5, 50000, data)), - CatalogSignal::Invalidate, + assert!( + t.observe(&heap_block_record(RmId::Heap, 0x00, 5, 50000, data)) + .catalog_write ); - // HEAP_DELETE on pg_class: DROP shape, arms the sweep too - assert_eq!( - t.observe(&heap_block_record(RmId::Heap, 0x10, 5, 50000, vec![])), - CatalogSignal::InvalidateSweep, + // HEAP_DELETE on pg_class: DROP shape counts as a catalog write + assert!( + t.observe(&heap_block_record(RmId::Heap, 0x10, 5, 50000, vec![])) + .catalog_write ); // User-table write: no catalog effect - assert_eq!( - t.observe(&heap_block_record( + assert!( + !t.observe(&heap_block_record( RmId::Heap, 0x00, 5, 60000, vec![0u8; 16] - )), - CatalogSignal::None, + )) + .catalog_write ); // Malformed relmap: counted but not applied, no signal let mut r = relmap_record(5, &[(1247, 70000)]); r.main_data.to_mut().truncate(8); - assert_eq!(t.observe(&r), CatalogSignal::None); + assert!(!t.observe(&r).catalog_write); } #[test] @@ -755,7 +736,7 @@ mod tests { let mut t = CatalogTracker::new(); // User-table relfilenode (no relmap seen), harvest skipped let rec = heap_block_record(RmId::Heap, 0x00, 5, 50000, vec![0u8; 16]); - assert_eq!(t.observe(&rec), CatalogSignal::None); + assert!(!t.observe(&rec).catalog_write); assert_eq!(t.invalidation_signals_sent, 0); } @@ -824,35 +805,4 @@ mod tests { t.observe(&r); assert!(!t.is_catalog(5, 50000)); } - - #[test] - fn pending_sweeps_consumed_only_at_arming_xacts_commit() { - let p = PendingSweeps::new(); - p.arm(100); - // Interleaved commit of another xact must not consume the arm: - // the drop isn't commit-visible in shadow before xid 100 commits - assert!(!p.disarm(200, None, &[])); - assert!(p.disarm(100, None, &[])); - assert!(!p.disarm(100, None, &[]), "single consumption"); - } - - #[test] - fn pending_sweeps_disarm_matches_subxact_and_twophase() { - let p = PendingSweeps::new(); - // heap_delete written under subxact 101, top commit lists it - p.arm(101); - assert!(p.disarm(100, None, &[101, 102])); - // COMMIT PREPARED: header xid differs, prepared xid in payload - p.arm(300); - assert!(p.disarm(0, Some(300), &[])); - } - - #[test] - fn pending_sweeps_abort_disarms_without_refire() { - let p = PendingSweeps::new(); - p.arm(100); - // Abort path discards the result; later commits must not sweep - assert!(p.disarm(100, None, &[])); - assert!(!p.disarm(100, None, &[])); - } } diff --git a/src/filter/dirty_tree.rs b/src/filter/dirty_tree.rs new file mode 100644 index 00000000..5ca7138e --- /dev/null +++ b/src/filter/dirty_tree.rs @@ -0,0 +1,273 @@ +//! Pump-side catalog-dirty transaction tree. +//! +//! State stays keyed by writing xid, never merged eagerly into the top: +//! subxact abort must drop exactly its subtree's observations while sibling +//! and top dirt survive. Tree links (subxid → top) arrive from record-inline +//! toplevel xids (`XLR_BLOCK_ID_TOPLEVEL_XID`, first record of each assigned +//! subxact at `wal_level=logical`) and batched `XLOG_XACT_ASSIGNMENT` +//! records (every `PGPROC_MAX_CACHED_SUBXIDS` assignments); both name the +//! top xid directly, so links never chain. Admission asks "is this record's +//! tree dirty at this position" via a per-root dirty-member count. A subxid +//! whose top is still unknown counts as its own root: only its own later +//! records defer, tree-wide dirt applies once a link lands (spec: prefer +//! excess raw buffering over predecessor decode) + +use std::collections::HashMap; +use std::collections::hash_map::Entry; + +/// One catalog-dirty xid's accumulated capture inputs +#[derive(Debug)] +pub(crate) struct DirtyState { + /// First catalog-touching record LSN under this xid + pub(crate) first_touch: u64, + /// User oid → first pg_class touch LSN under this xid + pub(crate) oids: HashMap, + /// Wrote a capture-all catalog (pg_namespace) + pub(crate) unenumerated: bool, +} + +impl DirtyState { + pub(crate) fn new(first_touch: u64) -> Self { + Self { + first_touch, + oids: HashMap::new(), + unenumerated: false, + } + } + + fn absorb(&mut self, other: Self) { + self.first_touch = self.first_touch.min(other.first_touch); + self.unenumerated |= other.unenumerated; + for (oid, lsn) in other.oids { + self.oids + .entry(oid) + .and_modify(|l| *l = (*l).min(lsn)) + .or_insert(lsn); + } + } +} + +#[derive(Debug, Default)] +pub(crate) struct DirtyTree { + /// subxid → top xid; entries drop at their tree's commit / abort. + /// Crash-orphaned xids linger, bounded by workload (no xact end ever + /// arrives to hold on) + top_by_xid: HashMap, + /// Dirty capture state per writing xid (top or sub) + state_by_xid: HashMap, + /// Tree root → dirty member count. Invariant: every `state_by_xid` + /// entry is counted once under its current root (`top_by_xid` value, + /// else itself); late links move the credit + dirty_members: HashMap, +} + +impl DirtyTree { + fn root(&self, xid: u32) -> u32 { + self.top_by_xid.get(&xid).copied().unwrap_or(xid) + } + + /// Record subxid → top. Late link (child dirtied before its top was + /// known) re-credits the child's dirty mark to the true top + pub(crate) fn link(&mut self, sub: u32, top: u32) { + if sub == 0 || top == 0 || sub == top { + return; + } + // Until its top is known a dirty child credits itself; first link + // moves that credit, re-links find it already at the root + if self.top_by_xid.insert(sub, top).is_none() && self.state_by_xid.contains_key(&sub) { + decrement(&mut self.dirty_members, sub); + *self.dirty_members.entry(top).or_default() += 1; + } + } + + /// Mark writing xid dirty at `lsn`; caller updates observation fields + /// on the returned state + pub(crate) fn touch(&mut self, xid: u32, lsn: u64) -> &mut DirtyState { + let root = self.root(xid); + match self.state_by_xid.entry(xid) { + Entry::Occupied(e) => e.into_mut(), + Entry::Vacant(e) => { + *self.dirty_members.entry(root).or_default() += 1; + e.insert(DirtyState::new(lsn)) + } + } + } + + /// Any member of `xid`'s known tree wrote catalog state still pending + /// at this stream position + pub(crate) fn is_dirty(&self, xid: u32) -> bool { + xid != 0 && self.dirty_members.contains_key(&self.root(xid)) + } + + /// 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 + pub(crate) fn drain_tree( + &mut self, + header_xid: u32, + twophase_xid: Option, + subxacts: &[u32], + ) -> Option { + // 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; + } + let roots = [Some(header_xid), twophase_xid]; + let mut members: Vec = roots + .iter() + .flatten() + .copied() + .chain(subxacts.iter().copied()) + .collect(); + members.extend( + self.top_by_xid + .iter() + .filter(|(_, top)| roots.contains(&Some(**top))) + .map(|(x, _)| *x), + ); + let mut merged: Option = None; + for x in members { + let Some(state) = self.state_by_xid.remove(&x) else { + self.top_by_xid.remove(&x); + continue; + }; + let root = self.root(x); + decrement(&mut self.dirty_members, root); + self.top_by_xid.remove(&x); + match &mut merged { + None => merged = Some(state), + Some(m) => m.absorb(state), + } + } + merged + } +} + +fn decrement(counts: &mut HashMap, key: u32) { + if let Some(n) = counts.get_mut(&key) { + *n -= 1; + if *n == 0 { + counts.remove(&key); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn touch_dirties_own_xid_only() { + let mut t = DirtyTree::default(); + t.touch(7, 100); + assert!(t.is_dirty(7)); + assert!(!t.is_dirty(8), "unrelated xid never inherits"); + assert!(!t.is_dirty(0), "invalid xid is never dirty"); + } + + #[test] + fn linked_child_dirt_covers_whole_tree() { + let mut t = DirtyTree::default(); + t.link(101, 100); + t.touch(101, 100); + assert!(t.is_dirty(100), "top dirty via child"); + assert!(t.is_dirty(101)); + t.link(102, 100); + assert!(t.is_dirty(102), "sibling dirty via shared root"); + assert!(!t.is_dirty(103), "unlinked xid stays clean"); + } + + #[test] + fn late_link_recredits_child_dirt() { + let mut t = DirtyTree::default(); + // Child dirtied before assignment named its top + t.touch(101, 100); + assert!(t.is_dirty(101)); + assert!(!t.is_dirty(100), "top unknown yet"); + t.link(101, 100); + assert!(t.is_dirty(100), "assignment merges retained state"); + assert!(t.is_dirty(101)); + // Idempotent re-link keeps single credit + t.link(101, 100); + t.drain_tree(100, None, &[101]); + assert!(!t.is_dirty(100)); + assert!(!t.is_dirty(101)); + } + + #[test] + fn subxact_drain_keeps_sibling_and_top_dirt() { + let mut t = DirtyTree::default(); + t.link(101, 100); + t.link(102, 100); + t.touch(101, 10); + t.touch(102, 20); + t.touch(100, 30); + // ROLLBACK TO SAVEPOINT: abort record for 101 alone + let dropped = t.drain_tree(101, None, &[]); + assert_eq!(dropped.expect("state").first_touch, 10); + 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"); + assert_eq!(merged.first_touch, 20); + assert!(!t.is_dirty(100)); + assert!(!t.is_dirty(102)); + } + + #[test] + fn drain_merges_oids_and_flags() { + let mut t = DirtyTree::default(); + t.touch(7, 100).oids.insert(16400, 100); + let s = t.touch(101, 50); + s.oids.insert(16400, 50); + s.oids.insert(16500, 60); + s.unenumerated = true; + let m = t.drain_tree(7, None, &[101]).expect("merge"); + assert_eq!(m.first_touch, 50); + assert!(m.unenumerated); + assert_eq!(m.oids[&16400], 50, "min lsn wins"); + assert_eq!(m.oids[&16500], 60); + } + + #[test] + fn drain_sweeps_unlisted_linked_members() { + let mut t = DirtyTree::default(); + 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"); + assert_eq!(m.first_touch, 10); + assert!(!t.is_dirty(101)); + assert!(t.top_by_xid.is_empty(), "link table drained"); + } + + #[test] + fn twophase_drain_matches_prepared_root() { + let mut t = DirtyTree::default(); + 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"); + assert_eq!(m.first_touch, 10); + assert!(!t.is_dirty(300)); + assert!(!t.is_dirty(301)); + } + + #[test] + fn interleaved_trees_stay_isolated() { + let mut t = DirtyTree::default(); + t.link(101, 100); + t.touch(101, 10); + t.link(201, 200); + t.touch(201, 20); + assert!(t.is_dirty(100)); + assert!(t.is_dirty(200)); + t.drain_tree(100, None, &[101]); + assert!(!t.is_dirty(100)); + assert!(t.is_dirty(200), "draining one tree leaves the other"); + assert!(t.is_dirty(201)); + } +} diff --git a/src/filter/engine.rs b/src/filter/engine.rs index a73e8944..74ffc100 100644 --- a/src/filter/engine.rs +++ b/src/filter/engine.rs @@ -10,13 +10,23 @@ //! `CatalogTracker`. Unrecognised → ToShadow: correctness over bytes, //! wrongly suppressing a catalog record breaks shadow. -use walrus::pg::walparser::{XLogRecord, XLogRecordBlock}; +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use walrus::pg::walparser::{RelFileNode, RmId, XLogRecord, XLogRecordBlock}; + +use crate::decode::wal_xact::{ + XLOG_XACT_ABORT, XLOG_XACT_ABORT_PREPARED, XLOG_XACT_ASSIGNMENT, XLOG_XACT_COMMIT, + XLOG_XACT_COMMIT_PREPARED, XLOG_XACT_INVALIDATIONS, XLOG_XACT_OPMASK, XactPayloadError, + parse_xact_assignment, parse_xact_invalidations, parse_xact_payload, +}; use crate::filter::catalog_tracker::{CatalogTracker, CatalogTrackerStats}; 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::{CatalogSignal, Route, rmgr_label}; +use crate::record::{AffectedOid, BoundaryInfo, Route, rmgr_label}; +use crate::schema::FIRST_NORMAL_OBJECT_ID; #[derive(Debug, Default, Clone, Copy)] pub struct FilterStats { @@ -91,12 +101,72 @@ pub(crate) struct FilterSnapshot { catalog: CatalogTrackerStats, } +/// Full routing verdict for one record; see [`Filter::decide_record`]. +#[derive(Debug, Clone)] +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` + pub catalog_boundary: bool, + /// Capture input; `Some` iff `catalog_boundary` + pub boundary: 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, +} + +/// 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 +/// capture resolves descriptor tablespace to physical so both sides carry +/// concrete spcOid. FIFO-capped like the worker-side map (which stays +/// separate for stash admission). Shared with descriptor capture, same +/// task — uncontended. +#[derive(Debug, Default)] +pub struct SmgrMarkers { + map: HashMap, + order: VecDeque<(RelFileNode, u64)>, +} + +/// Mirror of the worker-side marker backstop +const SMGR_MARKER_CAP: usize = 65536; + +impl SmgrMarkers { + fn insert(&mut self, rfn: RelFileNode, lsn: u64) { + if self.map.insert(rfn, lsn) != Some(lsn) { + self.order.push_back((rfn, lsn)); + while self.order.len() > SMGR_MARKER_CAP { + if let Some((old, old_lsn)) = self.order.pop_front() + && self.map.get(&old) == Some(&old_lsn) + { + self.map.remove(&old); + } + } + } + } + + pub fn get(&self, rfn: RelFileNode) -> Option { + self.map.get(&rfn).copied() + } +} + /// Routes against the *post-update* catalog set so an XLOG_RELMAP_UPDATE /// introducing a new mapped-catalog filenumber immediately routes later /// records on that filenumber to shadow. pub struct Filter { tracker: CatalogTracker, stats: FilterStats, + /// Catalog-dirty transaction trees: xids (top or sub) that wrote a + /// catalog-touching record or logged a descriptor-relevant + /// `XLOG_XACT_INVALIDATIONS` set, plus subxid → top links; drained at + /// commit / abort + dirty: DirtyTree, + smgr_markers: Arc>, + /// Relcache-inval scope: accept db in {0, this}; `None` (unwired) + /// accepts any db + inval_db_oid: Option, } impl Filter { @@ -104,11 +174,29 @@ impl Filter { Self { tracker: CatalogTracker::new(), stats: FilterStats::default(), + dirty: DirtyTree::default(), + smgr_markers: Arc::new(Mutex::new(SmgrMarkers::default())), + inval_db_oid: None, } } + /// Scope relcache-inval extraction to the followed database + pub fn set_inval_db(&mut self, db_oid: u32) { + self.inval_db_oid = Some(db_oid); + } + + /// Capture reads rotation markers through this handle + pub fn smgr_markers(&self) -> Arc> { + self.smgr_markers.clone() + } + pub fn decide(&mut self, record: &XLogRecord) -> Route { - self.decide_with_signal(record).0 + // Offline callers (segment filter tool) have no LSN and no capture; + // a malformed commit payload only degrades boundary metadata there, + // and commit records route ToShadow either way + self.decide_record(record, 0, 0xD116) + .map(|v| v.route) + .unwrap_or(Route::ToShadow) } pub fn tracker(&self) -> &CatalogTracker { @@ -137,38 +225,234 @@ impl Filter { ) } - /// [`Self::decide`] plus the tracker's [`CatalogSignal`] verdict, stamped - /// on the outgoing [`Record`](crate::record::Record) so the decoder - /// worker bumps invalidation epochs at its own stream position (a + /// Route plus the tracker's [`CatalogSignal`] verdict, stamped on the + /// outgoing [`Record`](crate::record::Record) so the decoder worker + /// bumps invalidation epochs at its own stream position (a /// pump-position bump would be consumable before pre-DDL records finish - /// decoding; see `catalog_tracker` module doc) - pub fn decide_with_signal(&mut self, record: &XLogRecord) -> (Route, CatalogSignal) { - let signal = self.tracker.observe(record); + /// decoding; see `catalog_tracker` module doc), plus the + /// catalog-boundary verdict driving the pump's publication hold and + /// descriptor capture. `Err` = malformed commit payload: capture input + /// would be silently incomplete, poison the stream. + pub fn decide_record( + &mut self, + record: &XLogRecord, + source_lsn: u64, + page_magic: u16, + ) -> Result { + let obs = self.tracker.observe(record); let class = classify(record); - let route = match class { - Class::Special | Class::Catalog => Route::ToShadow, + // `catalog_touch` marks the record's xid dirty: only paths proving a + // catalog relation was written qualify. `Empty`'s None → ToShadow + // safe default must not dirty (would hold at unrelated commits) + let (route, catalog_touch) = match class { + Class::Catalog => (Route::ToShadow, catalog_mutation(record)), + // Relmap update (VACUUM FULL mapped catalog) is Special-class + Class::Special => (Route::ToShadow, obs.catalog_write), Class::User => { if any_block_is_catalog(&self.tracker, &record.blocks) { // tracker has filenodes the bootstrap classify rule misses - Route::ToShadow + (Route::ToShadow, catalog_mutation(record)) } else { - Route::ToDecoder + (Route::ToDecoder, false) } } Class::Empty => match main_data::relation_for_empty(record) { Some(rel) => { if self.tracker.is_catalog(rel.db_node, rel.rel_node) { - Route::ToShadow + (Route::ToShadow, true) } else { - Route::ToDecoder + (Route::ToDecoder, false) } } - None => Route::ToShadow, // safe default + None => (Route::ToShadow, false), // safe default }, }; + if record.header.resource_manager_id == RmId::Smgr as u8 + && record.header.info & 0xF0 == main_data::XLOG_SMGR_CREATE + && let Some((rfn, fork)) = main_data::parse_xl_smgr_create(&record.main_data) + && fork == main_data::MAIN_FORKNUM + { + self.smgr_markers + .lock() + .expect("smgr markers poisoned") + .insert(rfn, source_lsn); + } + let xid = record.header.xact_id; + // Subxid → top link rides the subxact's first record at + // wal_level=logical (`XLR_BLOCK_ID_TOPLEVEL_XID`); learn before + // touch and admission so both resolve the true root + self.dirty.link(xid, record.toplevel_xid); + if catalog_touch && xid != 0 { + let dirty = self.dirty.touch(xid, source_lsn); + if let Some(oid) = obs.pg_class_user_oid { + dirty.oids.entry(oid).or_insert(source_lsn); + } + if record.blocks.iter().any(|b| { + let r = b.header.location.rel; + self.tracker.is_capture_all_catalog(r.db_node, r.rel_node) + }) { + dirty.unenumerated = true; + } + } + let defer_catalog_decode = route == Route::ToDecoder && self.dirty.is_dirty(xid); + let boundary = self.observe_xact_end(record, source_lsn, page_magic)?; self.stats .record(class, route, record.header.total_record_length as u64); - (route, signal) + Ok(Verdict { + route, + catalog_boundary: boundary.is_some(), + boundary, + defer_catalog_decode, + }) + } + + /// Drain dirty xids at commit / abort. Commit of any dirty xid (top, + /// listed subxact, or prepared xid) is a catalog boundary; abort clears + /// without holding — rolled-back catalog changes never become visible + /// in shadow. Commit records carry the full committed-subxact list + /// (`xactGetCommittedChildren`), the authoritative boundary merge; + /// ASSIGNMENT / inline-toplevel links only sharpen mid-xact admission. + /// Defense: a commit carrying local relcache invals is a boundary even + /// when the dirty tracker missed every write. + fn observe_xact_end( + &mut self, + record: &XLogRecord, + source_lsn: u64, + page_magic: u16, + ) -> Result>, XactPayloadError> { + if record.header.resource_manager_id != RmId::Xact as u8 { + return Ok(None); + } + 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); + } + if op == XLOG_XACT_ASSIGNMENT { + // Batched subxid → top links (every PGPROC_MAX_CACHED_SUBXIDS + // assignments). Silent loss would leave later child records + // undeferred, so malformation poisons like commit payloads + let (top, subs) = parse_xact_assignment(&record.main_data) + .ok_or_else(|| XactPayloadError::new("xact assignment"))?; + for sub in subs { + self.dirty.link(sub, top); + } + return Ok(None); + } + 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); + } + 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); + if !is_commit { + return Ok(None); + } + // Local relcache invals: second oid source + capture-all trigger. + // db 0 = shared relation; user rels there are impossible, kept for + // symmetry with is_local_db + let mut capture_all = false; + let mut inval_oids: Vec = Vec::new(); + for inval in &payload.invals.relcache { + if !self.is_local_db(inval.db_id) { + continue; + } + if inval.rel_id == 0 { + capture_all = true; + } else if inval.rel_id >= FIRST_NORMAL_OBJECT_ID { + inval_oids.push(inval.rel_id); + } + } + // Namespace catcache / whole-catalog inval: restart-safe capture-all + // trigger. Commit records carry the xact tree's full inval set, so + // classification holds even when the resume floor passed the + // pg_namespace writes and the dirty tracker never saw them + if payload.invals.namespace.hits(|db| self.is_local_db(db)) { + capture_all = true; + } + let dirty_hit = merged.is_some(); + if !dirty_hit && inval_oids.is_empty() && !capture_all { + return Ok(None); + } + // Inval-only boundary (dirty tracker missed the writes): the + // commit record itself is the only LSN at hand. Later than any + // of the xact's rows, so its events order after them — safe for + // descriptor bias (newer reader reads older tuples) + let mut merged = merged.unwrap_or_else(|| DirtyState::new(source_lsn)); + for oid in inval_oids { + merged.oids.entry(oid).or_default(); + } + let mut oids: Vec = merged + .oids + .into_iter() + .map(|(oid, lsn)| AffectedOid { + oid, + pg_class_touch: (lsn != 0).then_some(lsn), + }) + .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, + }))) + } + + /// `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 + fn observe_xact_invals( + &mut self, + record: &XLogRecord, + source_lsn: u64, + page_magic: u16, + ) -> Result<(), XactPayloadError> { + let xid = record.header.xact_id; + if xid == 0 { + return Ok(()); + } + let invals = parse_xact_invalidations(&record.main_data, page_magic)?; + let namespace_hit = invals.namespace.hits(|db| self.is_local_db(db)); + let mut flush = false; + let mut oids: Vec = Vec::new(); + for inval in &invals.relcache { + if !self.is_local_db(inval.db_id) { + continue; + } + if inval.rel_id == 0 { + flush = true; + } else if inval.rel_id >= FIRST_NORMAL_OBJECT_ID { + oids.push(inval.rel_id); + } + } + if !namespace_hit && !flush && oids.is_empty() { + return Ok(()); + } + let dirty = self.dirty.touch(xid, source_lsn); + dirty.unenumerated |= namespace_hit || flush; + 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); + } + Ok(()) + } + + /// Inval scope: accept db in {0, followed}; `None` (unwired) accepts any + fn is_local_db(&self, db: u32) -> bool { + !self + .inval_db_oid + .is_some_and(|local| db != 0 && db != local) } pub fn rmgr_label(record: &XLogRecord) -> String { @@ -189,6 +473,31 @@ fn any_block_is_catalog(tracker: &CatalogTracker, blocks: &[XLogRecordBlock]) -> }) } +/// Catalog pages take physical writes from any backend — opportunistic +/// prune during a scan (PG `src/backend/access/heap/pruneheap.c`), tuple +/// locks, vacuum inplace stats — all stamped with the writer's xid and none +/// a logical catalog change. Only row mutations prove DDL; everything else +/// still routes ToShadow but must not dirty the writer's tree (a user xact +/// pruning a pg_class page would fence its own rows). Non-heap rmgrs +/// (catalog index writes) never dirty: their logical counterpart is the +/// heap record beside them +fn catalog_mutation(record: &XLogRecord) -> bool { + use crate::decode::heap_decoder::{ + XLOG_HEAP_DELETE, XLOG_HEAP_HOT_UPDATE, XLOG_HEAP_INSERT, XLOG_HEAP_OPMASK, + XLOG_HEAP_UPDATE, XLOG_HEAP2_MULTI_INSERT, + }; + let op = record.header.info & XLOG_HEAP_OPMASK; + let rm = record.header.resource_manager_id; + if rm == RmId::Heap as u8 { + matches!( + op, + XLOG_HEAP_INSERT | XLOG_HEAP_DELETE | XLOG_HEAP_UPDATE | XLOG_HEAP_HOT_UPDATE + ) + } else { + rm == RmId::Heap2 as u8 && op == XLOG_HEAP2_MULTI_INSERT + } +} + #[cfg(test)] mod tests { use super::*; @@ -297,20 +606,6 @@ mod tests { assert_eq!(f.decide(&new_cid_record(5, 20000)), Route::ToDecoder); } - #[test] - fn decide_with_signal_surfaces_tracker_verdict() { - use crate::record::CatalogSignal; - let mut f = Filter::new(); - // pg_class heap insert (info 0x00, undecodable empty body: coarse - // signal still fires) - let (route, signal) = f.decide_with_signal(&rec(RmId::Heap, &[(5, 1259)])); - assert_eq!(route, Route::ToShadow); - assert_eq!(signal, CatalogSignal::Invalidate); - let (route, signal) = f.decide_with_signal(&rec(RmId::Heap, &[(5, 20000)])); - assert_eq!(route, Route::ToDecoder); - assert_eq!(signal, CatalogSignal::None); - } - #[test] fn default_matches_new_and_rmgr_label_round_trips() { let _: Filter = Filter::default(); @@ -328,4 +623,637 @@ mod tests { assert_eq!(f.stats.kept, 1); assert_eq!(f.stats.dropped, 2); } + + fn rec_with_xid(rm: RmId, rels: &[(u32, u32)], xid: u32) -> XLogRecord<'static> { + let mut r = rec(rm, rels); + r.header.xact_id = xid; + r + } + + /// `xl_xact_commit` / `xl_xact_abort` main_data: xact_time, then + /// optional xinfo + subxact / inval / twophase sections. Invals encode + /// as 16-byte `SharedInvalidationMessage`s: `(id, dbId, relId)`. + fn xact_end_full( + op: u8, + xid: u32, + subxacts: &[u32], + invals: &[(i8, u32, u32)], + twophase: Option, + ) -> XLogRecord<'static> { + let mut info = op; + let mut md: Vec = 0i64.to_le_bytes().to_vec(); + if !subxacts.is_empty() || twophase.is_some() || !invals.is_empty() { + info |= XLOG_XACT_HAS_INFO; + let mut xinfo = 0u32; + if !subxacts.is_empty() { + xinfo |= 1 << 1; // XACT_XINFO_HAS_SUBXACTS + } + if !invals.is_empty() { + xinfo |= 1 << 3; // XACT_XINFO_HAS_INVALS + } + if twophase.is_some() { + xinfo |= 1 << 4; // XACT_XINFO_HAS_TWOPHASE + } + md.extend_from_slice(&xinfo.to_le_bytes()); + if !subxacts.is_empty() { + md.extend_from_slice(&(subxacts.len() as i32).to_le_bytes()); + for x in subxacts { + md.extend_from_slice(&x.to_le_bytes()); + } + } + if !invals.is_empty() { + md.extend_from_slice(&(invals.len() as i32).to_le_bytes()); + for &(id, db, rel) in invals { + let mut msg = [0u8; 16]; + msg[0] = id as u8; + msg[4..8].copy_from_slice(&db.to_le_bytes()); + msg[8..12].copy_from_slice(&rel.to_le_bytes()); + md.extend_from_slice(&msg); + } + } + if let Some(x) = twophase { + md.extend_from_slice(&x.to_le_bytes()); + } + } + let mut r = rec_with_xid(RmId::Xact, &[], xid); + r.header.info = info; + r.main_data = std::borrow::Cow::Owned(md); + r + } + + fn xact_end(op: u8, xid: u32, subxacts: &[u32], twophase: Option) -> XLogRecord<'static> { + xact_end_full(op, xid, subxacts, &[], twophase) + } + + use crate::decode::wal_xact::XLOG_XACT_HAS_INFO; + + #[test] + fn catalog_commit_is_boundary_dml_commit_is_not() { + let mut f = Filter::new(); + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 1259)], 7), 0, 0xD116) + .unwrap(); + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 20000)], 8), 0, 0xD116) + .unwrap(); + // DML-only xid 8 commit: never parks + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 8, &[], None), 0, 0xD116) + .unwrap(); + assert!(!v.catalog_boundary); + // Catalog-dirty xid 7 commit: boundary, drained after + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 0, 0xD116) + .unwrap(); + assert!(v.catalog_boundary); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 0, 0xD116) + .unwrap(); + assert!(!v.catalog_boundary, "dirty mark consumed once"); + } + + #[test] + fn abort_clears_dirty_without_boundary() { + let mut f = Filter::new(); + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 1259)], 7), 0, 0xD116) + .unwrap(); + let v = f + .decide_record(&xact_end(XLOG_XACT_ABORT, 7, &[], None), 0, 0xD116) + .unwrap(); + assert!(!v.catalog_boundary, "rolled-back DDL never holds"); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 0, 0xD116) + .unwrap(); + assert!(!v.catalog_boundary, "abort drained the mark"); + } + + #[test] + fn subxact_catalog_write_marks_top_commit() { + let mut f = Filter::new(); + // DDL under savepoint: catalog record carries subxid 101 + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 1259)], 101), 0, 0xD116) + .unwrap(); + let v = f + .decide_record( + &xact_end(XLOG_XACT_COMMIT, 100, &[101, 102], None), + 0, + 0xD116, + ) + .unwrap(); + assert!(v.catalog_boundary); + } + + #[test] + fn defer_flag_tracks_dirty_tree() { + let mut f = Filter::new(); + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 1259)], 7), 100, 0xD116) + .unwrap(); + let user = |f: &mut Filter, xid| { + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 20000)], xid), 110, 0xD116) + .unwrap() + }; + assert!(user(&mut f, 7).defer_catalog_decode, "post-touch user row"); + assert!(!user(&mut f, 8).defer_catalog_decode, "interleaved xid"); + f.decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 200, 0xD116) + .unwrap(); + assert!(!user(&mut f, 7).defer_catalog_decode, "commit clears"); + } + + #[test] + fn inline_toplevel_defers_whole_tree_until_subxact_abort() { + let mut f = Filter::new(); + // Subxact's catalog write carries its top inline (logical WAL) + let mut ddl = rec_with_xid(RmId::Heap, &[(5, 1259)], 101); + ddl.toplevel_xid = 100; + f.decide_record(&ddl, 100, 0xD116).unwrap(); + let user = |f: &mut Filter, xid| { + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 20000)], xid), 110, 0xD116) + .unwrap() + .defer_catalog_decode + }; + assert!(user(&mut f, 100), "top defers via dirty child"); + assert!(user(&mut f, 101)); + // ROLLBACK TO SAVEPOINT drops the child's dirt, top runs clean + f.decide_record(&xact_end(XLOG_XACT_ABORT, 101, &[], None), 150, 0xD116) + .unwrap(); + assert!(!user(&mut f, 100), "subxact abort clears its subtree"); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 100, &[], None), 200, 0xD116) + .unwrap(); + assert!(!v.catalog_boundary, "aborted child never bounds"); + } + + #[test] + fn catalog_page_maintenance_never_dirties() { + use crate::decode::heap_decoder::{XLOG_HEAP_INPLACE, XLOG_HEAP_LOCK}; + let mut f = Filter::new(); + let user = |f: &mut Filter, xid| { + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 20000)], xid), 110, 0xD116) + .unwrap() + }; + // Opportunistic prune on a pg_class page carries the scanning + // xact's xid (PRUNE_ON_ACCESS = 0x10 on PG 17) + let mut prune = rec_with_xid(RmId::Heap2, &[(5, 1259)], 7); + prune.header.info = 0x10; + assert_eq!( + f.decide_record(&prune, 100, 0xD116).unwrap().route, + Route::ToShadow, + "maintenance still routes to shadow" + ); + assert!( + !user(&mut f, 7).defer_catalog_decode, + "prune must not fence the pruner's own rows" + ); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 200, 0xD116) + .unwrap(); + assert!(!v.catalog_boundary, "prune-only commit never bounds"); + // Tuple lock and vacuum inplace stats: same treatment + for info in [XLOG_HEAP_LOCK, XLOG_HEAP_INPLACE] { + let mut r = rec_with_xid(RmId::Heap, &[(5, 1259)], 8); + r.header.info = info; + f.decide_record(&r, 300, 0xD116).unwrap(); + } + assert!(!user(&mut f, 8).defer_catalog_decode); + // Real mutation still dirties + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 1259)], 9), 400, 0xD116) + .unwrap(); + assert!(user(&mut f, 9).defer_catalog_decode); + } + + /// `xl_xact_assignment`: `(u32 xtop, i32 nsubxacts, subxids…)` + fn xact_assignment(top: u32, subs: &[u32]) -> XLogRecord<'static> { + let mut md = top.to_le_bytes().to_vec(); + md.extend_from_slice(&(subs.len() as i32).to_le_bytes()); + for s in subs { + md.extend_from_slice(&s.to_le_bytes()); + } + let mut r = rec_with_xid(RmId::Xact, &[], subs.first().copied().unwrap_or(0)); + r.header.info = XLOG_XACT_ASSIGNMENT; + r.main_data = std::borrow::Cow::Owned(md); + r + } + + #[test] + fn assignment_merges_retained_child_state() { + let mut f = Filter::new(); + // Child dirties without inline toplevel (replica-level WAL shape) + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 1259)], 101), 100, 0xD116) + .unwrap(); + let user = |f: &mut Filter, xid| { + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 20000)], xid), 110, 0xD116) + .unwrap() + .defer_catalog_decode + }; + assert!(user(&mut f, 101), "child defers after own touch"); + assert!(!user(&mut f, 100), "top unknown until assignment"); + f.decide_record(&xact_assignment(100, &[101, 102]), 120, 0xD116) + .unwrap(); + assert!(user(&mut f, 100), "assignment merges retained state"); + assert!(user(&mut f, 102), "assigned sibling shares the tree"); + let v = f + .decide_record( + &xact_end(XLOG_XACT_COMMIT, 100, &[101, 102], None), + 200, + 0xD116, + ) + .unwrap(); + assert!(v.catalog_boundary); + assert!(!user(&mut f, 100), "commit clears the tree"); + } + + #[test] + fn malformed_assignment_poisons() { + let mut f = Filter::new(); + let mut r = xact_assignment(100, &[101]); + // Claim two subxids, carry one + match &mut r.main_data { + std::borrow::Cow::Owned(md) => md[4..8].copy_from_slice(&2i32.to_le_bytes()), + _ => unreachable!(), + } + assert!(f.decide_record(&r, 150, 0xD116).is_err()); + } + + #[test] + fn commit_prepared_matches_prepared_xid() { + let mut f = Filter::new(); + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 1259)], 300), 0, 0xD116) + .unwrap(); + // COMMIT PREPARED: header xid differs, prepared xid in payload + let v = f + .decide_record( + &xact_end(XLOG_XACT_COMMIT_PREPARED, 0, &[], Some(300)), + 0, + 0xD116, + ) + .unwrap(); + assert!(v.catalog_boundary); + } + + #[test] + fn relmap_update_marks_writing_xid() { + use crate::filter::catalog_tracker::test_relmap_record as relmap; + let mut f = Filter::new(); + let mut r = relmap(5, &[(1259, 50000)]); + r.header.xact_id = 9; + f.decide_record(&r, 0, 0xD116).unwrap(); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 9, &[], None), 0, 0xD116) + .unwrap(); + assert!( + v.catalog_boundary, + "VACUUM FULL relmap write holds at commit" + ); + } + + #[test] + fn empty_safe_default_route_does_not_dirty() { + let mut f = Filter::new(); + // Class::Empty, unrecognised main_data → ToShadow safe default + let r = rec_with_xid(RmId::Heap, &[], 7); + assert_eq!( + f.decide_record(&r, 0, 0xD116).unwrap().route, + Route::ToShadow + ); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 0, 0xD116) + .unwrap(); + assert!( + !v.catalog_boundary, + "safe-default keep is not a catalog touch" + ); + } + + #[test] + fn tracker_promoted_user_record_dirties() { + let mut f = Filter::new(); + f.tracker.add(5, 50000); // rotated mapped catalog above 16384 + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 50000)], 7), 0, 0xD116) + .unwrap(); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 0, 0xD116) + .unwrap(); + assert!(v.catalog_boundary); + } + + #[test] + fn boundary_merges_inval_oids_and_first_touch() { + let mut f = Filter::new(); + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 1259)], 7), 100, 0xD116) + .unwrap(); + // Commit carries relcache invals: local user rel + skippable ids + let commit = xact_end_full( + XLOG_XACT_COMMIT, + 7, + &[], + &[ + (7, 5, 0), // catcache: skip + (-2, 5, 16400), // relcache, local user rel + (-2, 5, 1259), // relcache on a catalog oid: filtered + (-3, 5, 16400), // smgr: skip + (-6, 5, 16400), // relsync (PG 18): skip + ], + None, + ); + let v = f.decide_record(&commit, 200, 0xD116).unwrap(); + let b = v.boundary.expect("boundary"); + assert_eq!(b.drain_xid, 7); + assert_eq!(b.tree_first_touch, 100); + assert!(!b.capture_all); + assert_eq!(b.oids.len(), 1); + assert_eq!(b.oids[0].oid, 16400); + assert_eq!(b.oids[0].pg_class_touch, None, "inval-sourced oid"); + } + + #[test] + fn inval_only_commit_is_boundary_defense() { + let mut f = Filter::new(); + // Dirty tracker saw nothing, but the commit proves catalog effects + let commit = xact_end_full(XLOG_XACT_COMMIT, 9, &[], &[(-2, 5, 16500)], None); + let v = f.decide_record(&commit, 300, 0xD116).unwrap(); + let b = v.boundary.expect("inval-only boundary"); + assert_eq!(b.tree_first_touch, 300, "commit lsn fallback"); + assert_eq!(b.oids[0].oid, 16500); + } + + #[test] + fn whole_relcache_inval_forces_capture_all() { + let mut f = Filter::new(); + let commit = xact_end_full(XLOG_XACT_COMMIT, 9, &[], &[(-2, 5, 0)], None); + let v = f.decide_record(&commit, 300, 0xD116).unwrap(); + assert!(v.boundary.expect("boundary").capture_all); + } + + #[test] + fn pg_namespace_write_forces_capture_all() { + let mut f = Filter::new(); + // Namespace rename: pg_namespace heap write, zero relcache oids + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 2615)], 7), 100, 0xD116) + .unwrap(); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 200, 0xD116) + .unwrap(); + let b = v.boundary.expect("boundary"); + assert!(b.capture_all); + assert!(b.oids.is_empty()); + } + + /// `xl_xact_invals`: `(i32 nmsgs, nmsgs × 16-byte msg)`, same message + /// encoding as commit invals + fn xact_invals_rec(xid: u32, invals: &[(i8, u32, u32)]) -> XLogRecord<'static> { + let mut md = (invals.len() as i32).to_le_bytes().to_vec(); + for &(id, db, arg) in invals { + let mut msg = [0u8; 16]; + msg[0] = id as u8; + msg[4..8].copy_from_slice(&db.to_le_bytes()); + msg[8..12].copy_from_slice(&arg.to_le_bytes()); + md.extend_from_slice(&msg); + } + let mut r = rec_with_xid(RmId::Xact, &[], xid); + r.header.info = XLOG_XACT_INVALIDATIONS; + r.main_data = std::borrow::Cow::Owned(md); + r + } + + #[test] + fn namespace_catcache_commit_is_capture_all_boundary() { + let mut f = Filter::new(); + // ALTER SCHEMA RENAME whose pg_namespace writes precede the resume + // floor: commit carries only catcache invals (NAMESPACENAME = 35 on + // PG 16-17) + let commit = xact_end_full(XLOG_XACT_COMMIT, 9, &[], &[(35, 5, 0xBEEF)], None); + let v = f.decide_record(&commit, 300, 0xD116).unwrap(); + let b = v.boundary.expect("namespace catcache boundary"); + assert!(b.capture_all); + assert!(b.oids.is_empty()); + assert_eq!(b.tree_first_touch, 300, "commit lsn fallback"); + } + + #[test] + fn namespace_catcache_ids_keyed_per_major() { + let mut f = Filter::new(); + // 35 is a different syscache on PG 18 (namespace ids shift to 37/38) + let commit = xact_end_full(XLOG_XACT_COMMIT, 9, &[], &[(35, 5, 0)], None); + assert!( + f.decide_record(&commit, 300, 0xD118) + .unwrap() + .boundary + .is_none() + ); + let commit = xact_end_full(XLOG_XACT_COMMIT, 10, &[], &[(37, 5, 0)], None); + let v = f.decide_record(&commit, 300, 0xD118).unwrap(); + assert!(v.boundary.expect("PG 18 namespace id").capture_all); + } + + #[test] + fn irrelevant_catcache_commit_is_not_boundary() { + let mut f = Filter::new(); + // STATRELATTINH (63): ANALYZE-rate churn must not bound + let commit = xact_end_full(XLOG_XACT_COMMIT, 9, &[], &[(63, 5, 0xBEEF)], None); + assert!( + f.decide_record(&commit, 300, 0xD116) + .unwrap() + .boundary + .is_none() + ); + } + + #[test] + fn namespace_catcache_foreign_db_filtered() { + let mut f = Filter::new(); + f.set_inval_db(5); + let commit = xact_end_full(XLOG_XACT_COMMIT, 9, &[], &[(35, 6, 0)], None); + assert!( + f.decide_record(&commit, 300, 0xD116) + .unwrap() + .boundary + .is_none() + ); + } + + #[test] + fn catalog_inval_on_pg_namespace_forces_capture_all() { + let mut f = Filter::new(); + // VACUUM FULL pg_namespace: whole-catalog msg names catId directly + let commit = xact_end_full(XLOG_XACT_COMMIT, 9, &[], &[(-1, 5, 2615)], None); + let v = f.decide_record(&commit, 300, 0xD116).unwrap(); + assert!(v.boundary.expect("catalog inval boundary").capture_all); + let commit = xact_end_full(XLOG_XACT_COMMIT, 10, &[], &[(-1, 5, 1259)], None); + assert!( + f.decide_record(&commit, 300, 0xD116) + .unwrap() + .boundary + .is_none() + ); + } + + #[test] + fn midxact_invals_dirty_xid() { + let mut f = Filter::new(); + // Restart lost the pg_class write; command-end inval set re-dirties + f.decide_record(&xact_invals_rec(7, &[(-2, 5, 16400)]), 150, 0xD116) + .unwrap(); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 200, 0xD116) + .unwrap(); + let b = v.boundary.expect("boundary"); + assert_eq!(b.tree_first_touch, 150); + assert!(!b.capture_all); + assert_eq!(b.oids.len(), 1); + assert_eq!(b.oids[0].oid, 16400); + assert_eq!(b.oids[0].pg_class_touch, Some(150)); + } + + #[test] + fn midxact_namespace_inval_forces_capture_all() { + let mut f = Filter::new(); + f.decide_record(&xact_invals_rec(7, &[(35, 5, 0xAB)]), 150, 0xD116) + .unwrap(); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 200, 0xD116) + .unwrap(); + let b = v.boundary.expect("boundary"); + assert!(b.capture_all); + assert_eq!(b.tree_first_touch, 150); + } + + #[test] + fn midxact_whole_relcache_flush_forces_capture_all() { + let mut f = Filter::new(); + f.decide_record(&xact_invals_rec(7, &[(-2, 5, 0)]), 150, 0xD116) + .unwrap(); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 200, 0xD116) + .unwrap(); + assert!(v.boundary.expect("boundary").capture_all); + } + + #[test] + fn midxact_irrelevant_invals_do_not_dirty() { + let mut f = Filter::new(); + // Non-namespace catcache + relcache on a catalog oid: nothing to + // capture, an entry would hold publication at commit for nothing + f.decide_record( + &xact_invals_rec(7, &[(63, 5, 0xAB), (-2, 5, 1259)]), + 150, + 0xD116, + ) + .unwrap(); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 200, 0xD116) + .unwrap(); + assert!(v.boundary.is_none()); + } + + #[test] + fn midxact_inval_foreign_db_filtered() { + let mut f = Filter::new(); + f.set_inval_db(5); + f.decide_record( + &xact_invals_rec(7, &[(-2, 6, 16400), (35, 6, 0)]), + 150, + 0xD116, + ) + .unwrap(); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 200, 0xD116) + .unwrap(); + assert!(v.boundary.is_none()); + } + + #[test] + fn midxact_inval_abort_clears() { + let mut f = Filter::new(); + f.decide_record(&xact_invals_rec(7, &[(-2, 5, 16400)]), 150, 0xD116) + .unwrap(); + let v = f + .decide_record(&xact_end(XLOG_XACT_ABORT, 7, &[], None), 200, 0xD116) + .unwrap(); + assert!(!v.catalog_boundary); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 300, 0xD116) + .unwrap(); + assert!(!v.catalog_boundary, "abort drained the mark"); + } + + #[test] + fn midxact_inval_under_subxact_merges_at_top_commit() { + let mut f = Filter::new(); + f.decide_record(&xact_invals_rec(101, &[(-2, 5, 16400)]), 150, 0xD116) + .unwrap(); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 100, &[101], None), 200, 0xD116) + .unwrap(); + assert_eq!(v.boundary.expect("boundary").oids[0].oid, 16400); + } + + #[test] + fn malformed_midxact_inval_record_poisons() { + let mut f = Filter::new(); + let mut r = xact_invals_rec(7, &[(-2, 5, 16400)]); + // Claim two messages, carry one + match &mut r.main_data { + std::borrow::Cow::Owned(md) => md[0..4].copy_from_slice(&2i32.to_le_bytes()), + _ => unreachable!(), + } + assert!(f.decide_record(&r, 150, 0xD116).is_err()); + } + + #[test] + fn inval_db_scope_filters_foreign_db() { + let mut f = Filter::new(); + f.set_inval_db(5); + let commit = xact_end_full(XLOG_XACT_COMMIT, 9, &[], &[(-2, 6, 16500)], None); + let v = f.decide_record(&commit, 300, 0xD116).unwrap(); + assert!(v.boundary.is_none(), "foreign-db inval must not bound"); + } + + #[test] + fn prepared_commit_boundary_drains_under_prepared_xid() { + let mut f = Filter::new(); + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 1259)], 300), 100, 0xD116) + .unwrap(); + let v = f + .decide_record( + &xact_end(XLOG_XACT_COMMIT_PREPARED, 0, &[], Some(300)), + 400, + 0xD116, + ) + .unwrap(); + assert_eq!(v.boundary.expect("boundary").drain_xid, 300); + } + + #[test] + fn unknown_sinval_id_poisons() { + let mut f = Filter::new(); + let commit = xact_end_full(XLOG_XACT_COMMIT, 9, &[], &[(-7, 5, 16500)], None); + assert!(f.decide_record(&commit, 300, 0xD116).is_err()); + } + + #[test] + fn smgr_create_records_pump_marker() { + use crate::filter::main_data::XLOG_SMGR_CREATE; + let mut f = Filter::new(); + let mut md = Vec::new(); + md.extend_from_slice(&1663u32.to_le_bytes()); + md.extend_from_slice(&5u32.to_le_bytes()); + md.extend_from_slice(&24000u32.to_le_bytes()); + md.extend_from_slice(&0i32.to_le_bytes()); // MAIN_FORKNUM + let mut r = rec(RmId::Smgr, &[]); + r.header.info = XLOG_SMGR_CREATE; + r.main_data = std::borrow::Cow::Owned(md); + f.decide_record(&r, 777, 0xD116).unwrap(); + let rfn = RelFileNode { + spc_node: 1663, + db_node: 5, + rel_node: 24000, + }; + assert_eq!(f.smgr_markers().lock().unwrap().get(rfn), Some(777)); + // Same (db, rel) under another tablespace is a distinct filenode + assert_eq!( + f.smgr_markers().lock().unwrap().get(RelFileNode { + spc_node: 9999, + ..rfn + }), + None + ); + } } diff --git a/src/filter/mod.rs b/src/filter/mod.rs index 403976f2..feccc865 100644 --- a/src/filter/mod.rs +++ b/src/filter/mod.rs @@ -6,6 +6,7 @@ pub mod manifest; pub mod pg_class_decoder; pub mod rewrite; +mod dirty_tree; mod engine; #[doc(hidden)] diff --git a/src/lib.rs b/src/lib.rs index fa03c165..1ad97e08 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::{shadow, shadow_catalog, type_bridge}; +pub use catalog::{desc_log, shadow, shadow_catalog, type_bridge}; #[doc(hidden)] pub use decode::{codecs, decoder_sink, fpi, heap_decoder, visibility, wal_xact}; #[doc(hidden)] @@ -55,7 +55,8 @@ pub use filter::{catalog_tracker, classify, filter_segment, main_data, pg_class_ pub use ops::{control, metrics, oracle, preflight, retention, trace}; #[doc(hidden)] pub use source::{ - manifest, queueing_record_sink, segment_sink, shadow_stream, source_feed, wal_stream, + boundary_hold, catalog_capture, manifest, queueing_record_sink, segment_sink, shadow_stream, + source_feed, wal_stream, }; #[doc(hidden)] pub use toast::toast_retire; diff --git a/src/mapping.rs b/src/mapping.rs index 9ddc6189..7cd3ac12 100644 --- a/src/mapping.rs +++ b/src/mapping.rs @@ -100,7 +100,17 @@ pub struct ToastConfig { pub mode: ToastMode, } -pub type MappingHandle = Arc>>; +/// Immutable routing-map version. Planners snapshot one per transaction so a +/// concurrent republish can't split a transaction across mapping versions +pub type MappingSnapshot = Arc>; + +/// Shared copy-on-write routing map: writers swap or `Arc::make_mut` the +/// inner snapshot, held snapshots stay frozen +pub type MappingHandle = Arc>; + +pub fn mapping_handle(tables: HashMap) -> MappingHandle { + Arc::new(tokio::sync::RwLock::new(Arc::new(tables))) +} pub fn derive_columns_for_mapping(desc: &RelDescriptor) -> Vec { let keys = replident_key_attnums(desc); @@ -148,3 +158,39 @@ pub fn fold_diff_into_mapping(target: &mut TableMapping, new: &RelDescriptor, di fn quote_ident(name: &str) -> String { format!("`{}`", name.replace('`', "``")) } + +#[cfg(test)] +mod tests { + use super::*; + + fn one_table() -> (RelName, HashMap) { + let rel = RelName::new("public", "t"); + let map = HashMap::from([( + rel.clone(), + TableMapping { + target: TableTarget::new("db", "t"), + columns: vec![], + }, + )]); + (rel, map) + } + + /// Held snapshot stays frozen under both writer shapes — a planned + /// transaction's route state can't be altered by a later mapping write + #[tokio::test] + async fn snapshot_immune_to_later_writes() { + let (rel, map) = one_table(); + let handle = mapping_handle(map); + // Applicator shape: make_mut clones out from under held snapshots + let planned: MappingSnapshot = handle.read().await.clone(); + Arc::make_mut(&mut *handle.write().await).remove(&rel); + assert!(planned.contains_key(&rel), "snapshot keeps its version"); + assert!(!handle.read().await.contains_key(&rel), "handle moved on"); + // Republish shape: full inner-Arc swap + let planned = handle.read().await.clone(); + let (rel2, map2) = one_table(); + *handle.write().await = Arc::new(map2); + assert!(!planned.contains_key(&rel2), "snapshot predates the swap"); + assert!(handle.read().await.contains_key(&rel2)); + } +} diff --git a/src/ops/control.rs b/src/ops/control.rs index 4e823626..a447d3b7 100644 --- a/src/ops/control.rs +++ b/src/ops/control.rs @@ -26,7 +26,20 @@ pub struct Reloader { impl Reloader { pub async fn set_resolver(&self, r: Option>) { - *self.resolver.lock().await = r; + *self.resolver.lock().await = r.clone(); + // Control socket serves before the session wires a resolver; + // apply/reload in that window persist fragments but republish + // nothing (reload() below no-ops on None). Sweep once at wiring so + // file state and published config converge + if let Some(r) = r + && let Err(e) = r.reload().await + { + tracing::warn!( + target: "walshadow::control", + error = %e, + "config sweep at resolver wiring failed", + ); + } } /// Live reconfigure: re-read the merged config + republish. No restart. diff --git a/src/ops/metrics.rs b/src/ops/metrics.rs index 2e3ee32d..15ab2d36 100644 --- a/src/ops/metrics.rs +++ b/src/ops/metrics.rs @@ -87,9 +87,34 @@ pub struct MetricsSnapshot { pub toast_mirror_retires_total: u64, pub toast_rewrite_barriers_total: u64, pub toast_stash_buffered_total: u64, + pub raw_stash_deferred_total: u64, pub toast_stash_decoded_total: u64, pub toast_stash_discarded_total: u64, - pub toast_stash_skipped_total: u64, + pub stash_foreign_db_skipped_total: u64, + /// Routed heaps sealed into transaction plans + pub xact_plan_rows: u64, + /// Sealed plan bytes `[mem, file]`, rendered `storage=` labelled + pub xact_plan_bytes_by_storage: [u64; 2], + /// Planning failures `[spool, fail_closed, detoast, partial_update, + /// view, drain]`, rendered `reason=` labelled + pub xact_plan_failures_by_reason: [u64; 11], + /// Plan-time route resolutions `[mapped, unmapped]`, rendered + /// `result=` labelled + pub route_snapshots_by_result: [u64; 2], + /// Raw-stash records `[dirty, marker]` × op + /// ([`crate::decode::heap_decoder::HEAP_OP_LABELS`]), rendered + /// `kind=`/`op=` labelled + pub raw_stash_records_by_kind_op: [[u64; 7]; 2], + /// Cumulative raw-stash bytes `[mem, spill]` by first landing, + /// rendered `storage=` labelled + pub raw_stash_bytes_by_storage: [u64; 2], + /// Commit-resolve raw decode records `[toast, ordinary]` × op + pub raw_decode_records_by_kind_op: [[u64; 7]; 2], + /// Rows fanned out of decoded raw records per op + pub raw_decode_rows_by_op: [u64; 7], + /// Gauges: raw-decoded heaps queued for pending-first yield + pub raw_pending_rows: u64, + pub raw_pending_bytes: u64, pub emitter_rows_total: u64, pub emitter_blocks_total: u64, pub emitter_xacts_total: u64, @@ -120,8 +145,6 @@ pub struct MetricsSnapshot { pub process_resident_memory_bytes: u64, pub oracle_resolved_total: u64, pub oracle_fallback_raw_total: u64, - pub oracle_validate_sampled_total: u64, - pub oracle_validate_mismatches_total: u64, pub oracle_errors_total: u64, pub uptime_secs: u64, /// `source_received_lsn - min_apply_lsn` across active shadow walreceivers. @@ -134,6 +157,40 @@ pub struct MetricsSnapshot { pub shadow_stream_active_connections: u64, /// Cumulative connections dropped by `slow_threshold` overflow pub shadow_stream_dropped_connections_total: u64, + /// Publication holds released at catalog-mutating commit boundaries + pub catalog_boundary_holds_total: u64, + /// Holds woken with Err (worker death, walreceiver loss, timeout) + pub catalog_boundary_hold_failures_total: u64, + /// Cumulative seconds spent parked in released holds + pub catalog_boundary_hold_seconds_total: f64, + /// Boundaries captured via shadow SQL fan-out + pub desc_capture_sql_total: u64, + /// Boundaries replayed from stored descriptor-log batches + pub desc_capture_log_replay_total: u64, + /// Boundaries at or below the seed's covered_through, skipped + pub desc_capture_skipped_covered_total: u64, + /// Capture-all boundaries (whole-relcache inval / pg_namespace write) + pub desc_capture_all_total: u64, + /// Descriptors fetched across SQL captures + pub desc_capture_rels_total: u64, + /// Cumulative seconds inside descriptor capture (part of the hold) + pub desc_capture_seconds_total: f64, + pub desc_events_added_total: u64, + pub desc_events_changed_total: u64, + pub desc_events_dropped_total: u64, + /// Descriptor-log index entries / tail bytes / batches + pub desc_log_entries: u64, + pub desc_log_tail_bytes: u64, + pub desc_log_batches: u64, + pub desc_log_gc_total: u64, + pub desc_log_gc_dropped_entries_total: u64, + pub desc_lookups_present_total: u64, + pub desc_lookups_dropped_total: u64, + pub desc_lookups_retired_total: u64, + pub desc_lookups_ambiguous_total: u64, + pub descriptor_ambiguous_total: u64, + pub desc_lookups_not_covered_total: u64, + pub desc_lookups_foreign_db_total: u64, } #[derive(Debug, Clone, Default)] @@ -222,6 +279,22 @@ impl Default for RateEstimator { /// Prometheus text-format. Each metric gets `# HELP` + `# TYPE`; counters use /// the `_total` suffix per Prom convention. +/// `reason=` label order of `MetricsSnapshot::xact_plan_failures_by_reason`, +/// matching [`crate::emit::pipeline::planner::drain_reason`] +const PLAN_FAILURE_REASONS: [&str; 11] = [ + "spool", + "fail_closed_image_only", + "fail_closed_malformed", + "fail_closed_unsupported_op", + "stash_ambiguous", + "incomplete_toast", + "missing_stash_resolution", + "detoast", + "partial_update", + "view", + "drain", +]; + pub fn render(snap: &MetricsSnapshot) -> String { use std::fmt::Write as _; let mut s = String::with_capacity(1024); @@ -450,6 +523,13 @@ pub fn render(snap: &MetricsSnapshot) -> String { "counter", snap.toast_stash_buffered_total, ), + ( + "walshadow_raw_stash_deferred_total", + "Records held raw because their xact tree wrote catalog state \ + earlier in the stream; resolved at commit.", + "counter", + snap.raw_stash_deferred_total, + ), ( "walshadow_toast_stash_decoded_total", "Stashed records decoded at commit against a resolved toast heap.", @@ -464,10 +544,11 @@ pub fn render(snap: &MetricsSnapshot) -> String { snap.toast_stash_discarded_total, ), ( - "walshadow_toast_stash_skipped_total", - "Stashed records resolved to a non-toast heap; decode fenced off.", + "walshadow_stash_foreign_db_skipped_total", + "Stashed filenodes resolved to a foreign database at commit, \ + counted once per filenode.", "counter", - snap.toast_stash_skipped_total, + snap.stash_foreign_db_skipped_total, ), ( "walshadow_emitter_rows_total", @@ -559,18 +640,6 @@ pub fn render(snap: &MetricsSnapshot) -> String { "counter", snap.oracle_fallback_raw_total, ), - ( - "walshadow_decode_validate_sampled_total", - "Rows the differential-decode sampler probed.", - "counter", - snap.oracle_validate_sampled_total, - ), - ( - "walshadow_decode_validate_mismatches_total", - "Sampled rows where local codec output ≠ shadow PG's render.", - "counter", - snap.oracle_validate_mismatches_total, - ), ( "walshadow_decode_errors_total", "Decode-bridge SQL errors swallowed by the fallback path.", @@ -601,6 +670,138 @@ pub fn render(snap: &MetricsSnapshot) -> String { "counter", snap.shadow_stream_dropped_connections_total, ), + ( + "walshadow_catalog_boundary_holds_total", + "Publication holds released at catalog-mutating commit boundaries.", + "counter", + snap.catalog_boundary_holds_total, + ), + ( + "walshadow_catalog_boundary_hold_failures_total", + "Catalog-boundary holds woken with an error (worker death, walreceiver loss, timeout).", + "counter", + snap.catalog_boundary_hold_failures_total, + ), + ( + "walshadow_desc_capture_total_sql", + "Catalog boundaries captured via shadow SQL fan-out.", + "counter", + snap.desc_capture_sql_total, + ), + ( + "walshadow_desc_capture_total_log_replay", + "Catalog boundaries replayed from stored descriptor-log batches.", + "counter", + snap.desc_capture_log_replay_total, + ), + ( + "walshadow_desc_capture_skipped_covered_total", + "Boundaries at or below the seed's covered_through, skipped.", + "counter", + snap.desc_capture_skipped_covered_total, + ), + ( + "walshadow_desc_capture_all_total", + "Capture-all boundaries (whole-relcache inval / pg_namespace write).", + "counter", + snap.desc_capture_all_total, + ), + ( + "walshadow_desc_capture_rels_total", + "Descriptors fetched across SQL captures.", + "counter", + snap.desc_capture_rels_total, + ), + ( + "walshadow_desc_events_added_total", + "Added schema events produced by descriptor capture.", + "counter", + snap.desc_events_added_total, + ), + ( + "walshadow_desc_events_changed_total", + "Changed schema events produced by descriptor capture.", + "counter", + snap.desc_events_changed_total, + ), + ( + "walshadow_desc_events_dropped_total", + "Dropped schema events produced by descriptor capture.", + "counter", + snap.desc_events_dropped_total, + ), + ( + "walshadow_desc_log_entries", + "Descriptor-log index entries resident.", + "gauge", + snap.desc_log_entries, + ), + ( + "walshadow_desc_log_tail_bytes", + "Descriptor-log tail bytes since last checkpoint.", + "gauge", + snap.desc_log_tail_bytes, + ), + ( + "walshadow_desc_log_batches", + "Descriptor-log batches resident.", + "gauge", + snap.desc_log_batches, + ), + ( + "walshadow_desc_log_gc_total", + "Descriptor-log checkpoint compactions.", + "counter", + snap.desc_log_gc_total, + ), + ( + "walshadow_desc_log_gc_dropped_entries_total", + "Entries dropped by descriptor-log GC.", + "counter", + snap.desc_log_gc_dropped_entries_total, + ), + ( + "walshadow_desc_lookups_present_total", + "Descriptor lookups answered Present.", + "counter", + snap.desc_lookups_present_total, + ), + ( + "walshadow_desc_lookups_dropped_total", + "Descriptor lookups answered Dropped.", + "counter", + snap.desc_lookups_dropped_total, + ), + ( + "walshadow_desc_lookups_retired_total", + "Descriptor lookups answered Retired (rotated-away filenode).", + "counter", + snap.desc_lookups_retired_total, + ), + ( + "walshadow_desc_lookups_ambiguous_total", + "Descriptor lookups landing in an ambiguity interval.", + "counter", + snap.desc_lookups_ambiguous_total, + ), + ( + "walshadow_descriptor_ambiguous_total", + "Ambiguity intervals published at capture for unproven in-place changes.", + "counter", + snap.descriptor_ambiguous_total, + ), + ( + "walshadow_desc_lookups_not_covered_total", + "Descriptor lookups answered NotCovered.", + "counter", + snap.desc_lookups_not_covered_total, + ), + ( + "walshadow_desc_lookups_foreign_db_total", + "Descriptor lookups on a foreign database's filenode.", + "counter", + snap.desc_lookups_foreign_db_total, + ), ( "walshadow_config_pending_decl_rels", "Forward-declared per-table opt-ins awaiting their CREATE TABLE.", @@ -626,6 +827,117 @@ pub fn render(snap: &MetricsSnapshot) -> String { writeln!(s, "{name} {value}").unwrap(); } + // Transaction-plan families, labelled series + { + let name = "walshadow_xact_plan_bytes"; + writeln!( + s, + "# HELP {name} Sealed transaction-plan bytes by final backing." + ) + .unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + for (storage, v) in ["mem", "file"].iter().zip(snap.xact_plan_bytes_by_storage) { + writeln!(s, "{name}{{storage=\"{storage}\"}} {v}").unwrap(); + } + let name = "walshadow_xact_plan_rows"; + writeln!( + s, + "# HELP {name} Routed heaps sealed into transaction plans." + ) + .unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + writeln!(s, "{name} {}", snap.xact_plan_rows).unwrap(); + let name = "walshadow_xact_plan_failures_total"; + writeln!( + s, + "# HELP {name} Planning-stage failures; the whole transaction emits nothing." + ) + .unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + for (reason, v) in PLAN_FAILURE_REASONS + .iter() + .zip(snap.xact_plan_failures_by_reason) + { + writeln!(s, "{name}{{reason=\"{reason}\"}} {v}").unwrap(); + } + let name = "walshadow_route_snapshots_total"; + writeln!( + s, + "# HELP {name} Plan-time route resolutions, one per relation per transaction." + ) + .unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + for (result, v) in ["mapped", "unmapped"] + .iter() + .zip(snap.route_snapshots_by_result) + { + writeln!(s, "{name}{{result=\"{result}\"}} {v}").unwrap(); + } + } + + // Raw stash/decode families, `kind=`/`op=` labelled + { + use crate::decode::heap_decoder::HEAP_OP_LABELS; + let name = "walshadow_raw_stash_records_total"; + writeln!( + s, + "# HELP {name} Records stashed raw for commit-time resolution." + ) + .unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + for (kind, ops) in ["dirty", "marker"] + .iter() + .zip(&snap.raw_stash_records_by_kind_op) + { + for (op, v) in HEAP_OP_LABELS.iter().zip(ops) { + writeln!(s, "{name}{{kind=\"{kind}\",op=\"{op}\"}} {v}").unwrap(); + } + } + let name = "walshadow_raw_stash_bytes"; + writeln!( + s, + "# HELP {name} Cumulative raw-stash payload bytes by first landing." + ) + .unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + for (storage, v) in ["mem", "spill"].iter().zip(snap.raw_stash_bytes_by_storage) { + writeln!(s, "{name}{{storage=\"{storage}\"}} {v}").unwrap(); + } + let name = "walshadow_raw_decode_records_total"; + writeln!( + s, + "# HELP {name} Stashed records decoded at commit resolution." + ) + .unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + for (kind, ops) in ["toast", "ordinary"] + .iter() + .zip(&snap.raw_decode_records_by_kind_op) + { + for (op, v) in HEAP_OP_LABELS.iter().zip(ops) { + writeln!(s, "{name}{{kind=\"{kind}\",op=\"{op}\"}} {v}").unwrap(); + } + } + let name = "walshadow_raw_decode_rows_total"; + writeln!(s, "# HELP {name} Rows fanned out of decoded raw records.").unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + for (op, v) in HEAP_OP_LABELS.iter().zip(snap.raw_decode_rows_by_op) { + writeln!(s, "{name}{{op=\"{op}\"}} {v}").unwrap(); + } + let name = "walshadow_raw_pending_rows"; + writeln!( + s, + "# HELP {name} Raw-decoded heaps queued for pending-first yield." + ) + .unwrap(); + writeln!(s, "# TYPE {name} gauge").unwrap(); + writeln!(s, "{name} {}", snap.raw_pending_rows).unwrap(); + let name = "walshadow_raw_pending_bytes"; + writeln!(s, "# HELP {name} Bytes held by the pending raw fanout.").unwrap(); + writeln!(s, "# TYPE {name} gauge").unwrap(); + writeln!(s, "{name} {}", snap.raw_pending_bytes).unwrap(); + } + // Umbrella count bare + per-mode labelled series in one family { let name = "walshadow_config_backfills_pending"; @@ -658,6 +970,23 @@ pub fn render(snap: &MetricsSnapshot) -> String { writeln!(s, "{name} {:.3}", snap.shadow_apply_lag_seconds).unwrap(); } + let name = "walshadow_catalog_boundary_hold_seconds_total"; + writeln!( + s, + "# HELP {name} Cumulative seconds the pump parked in released catalog-boundary holds.", + ) + .unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + writeln!(s, "{name} {:.3}", snap.catalog_boundary_hold_seconds_total).unwrap(); + let name = "walshadow_desc_capture_seconds_total"; + writeln!( + s, + "# HELP {name} Cumulative seconds inside descriptor capture (within the boundary hold)." + ) + .unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + writeln!(s, "{name} {:.3}", snap.desc_capture_seconds_total).unwrap(); + // Process CPU as a float counter (seconds); rate() ≈ cores in use. let name = "walshadow_process_cpu_seconds_total"; writeln!( @@ -765,6 +1094,95 @@ mod tests { assert!(body.contains("walshadow_uptime_seconds 42")); } + #[test] + fn render_emits_plan_families_labelled() { + let snap = MetricsSnapshot { + xact_plan_rows: 9, + xact_plan_bytes_by_storage: [100, 200], + xact_plan_failures_by_reason: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + route_snapshots_by_result: [7, 8], + ..MetricsSnapshot::default() + }; + let body = render(&snap); + assert!(body.contains("# TYPE walshadow_xact_plan_bytes counter")); + assert!(body.contains("walshadow_xact_plan_bytes{storage=\"mem\"} 100")); + assert!(body.contains("walshadow_xact_plan_bytes{storage=\"file\"} 200")); + assert!(body.contains("walshadow_xact_plan_rows 9")); + for (i, reason) in PLAN_FAILURE_REASONS.iter().enumerate() { + let want = format!( + "walshadow_xact_plan_failures_total{{reason=\"{reason}\"}} {}", + i + 1 + ); + assert!(body.contains(&want), "{want}"); + } + assert!(body.contains("walshadow_route_snapshots_total{result=\"mapped\"} 7")); + assert!(body.contains("walshadow_route_snapshots_total{result=\"unmapped\"} 8")); + } + + /// Prometheus rejects a family declared twice; `descriptor_ambiguous_total` + /// was emitted bare and labelled with contradicting HELP text + #[test] + fn render_declares_each_family_once() { + let body = render(&MetricsSnapshot::default()); + let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for line in body.lines() { + if let Some(rest) = line.strip_prefix("# TYPE ") + && let Some(name) = rest.split_whitespace().next() + { + *seen.entry(name).or_default() += 1; + } + } + let dupes: Vec<&&str> = seen + .iter() + .filter(|(_, n)| **n > 1) + .map(|(name, _)| name) + .collect(); + assert!(dupes.is_empty(), "families declared twice: {dupes:?}"); + let helps = body.lines().filter(|l| l.starts_with("# HELP ")).count(); + assert_eq!(helps, seen.len(), "one HELP per TYPE"); + } + + #[test] + fn render_emits_raw_families_labelled() { + let mut snap = MetricsSnapshot { + raw_stash_bytes_by_storage: [11, 12], + raw_decode_rows_by_op: [1, 2, 3, 4, 5, 6, 7], + raw_pending_rows: 13, + raw_pending_bytes: 14, + descriptor_ambiguous_total: 21, + desc_lookups_ambiguous_total: 22, + ..MetricsSnapshot::default() + }; + snap.raw_stash_records_by_kind_op[0][0] = 31; // dirty insert + snap.raw_stash_records_by_kind_op[1][5] = 32; // marker multi_insert + snap.raw_decode_records_by_kind_op[0][0] = 33; // toast insert + snap.raw_decode_records_by_kind_op[1][2] = 34; // ordinary update + let body = render(&snap); + assert!( + body.contains("walshadow_raw_stash_records_total{kind=\"dirty\",op=\"insert\"} 31") + ); + assert!( + body.contains( + "walshadow_raw_stash_records_total{kind=\"marker\",op=\"multi_insert\"} 32" + ) + ); + assert!(body.contains("walshadow_raw_stash_bytes{storage=\"mem\"} 11")); + assert!(body.contains("walshadow_raw_stash_bytes{storage=\"spill\"} 12")); + assert!( + body.contains("walshadow_raw_decode_records_total{kind=\"toast\",op=\"insert\"} 33") + ); + assert!( + body.contains("walshadow_raw_decode_records_total{kind=\"ordinary\",op=\"update\"} 34") + ); + assert!(body.contains("walshadow_raw_decode_rows_total{op=\"multi_insert\"} 6")); + assert!(body.contains("walshadow_raw_pending_rows 13")); + assert!(body.contains("walshadow_raw_pending_bytes 14")); + // Published intervals and lookups landing in one stay distinct + // families, each bare + assert!(body.contains("walshadow_descriptor_ambiguous_total 21")); + assert!(body.contains("walshadow_desc_lookups_ambiguous_total 22")); + } + #[tokio::test(flavor = "current_thread")] async fn registry_set_and_snapshot_round_trip() { let reg = MetricsRegistry::new(); diff --git a/src/ops/oracle.rs b/src/ops/oracle.rs index 7356751f..4670d40a 100644 --- a/src/ops/oracle.rs +++ b/src/ops/oracle.rs @@ -1,14 +1,10 @@ -//! Differential decode oracle backed by shadow PG. +//! PgPending resolver backed by shadow PG. //! -//! 1. PgPending resolver: 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. -//! 2. 1-in-N validator: sampled Tier 3 codec values (`numeric`/`inet`/`interval`) -//! cross-checked against shadow PG's typoutput. Mismatches counted + logged, -//! row still ships (watchdog, not gate). Off by default, `--validate `. +//! 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. //! //! Separate `Client` from [`ShadowCatalog`](crate::catalog::shadow_catalog::ShadowCatalog): //! oracle queries don't observe replay-LSN gating and mustn't pessimise the @@ -23,7 +19,6 @@ use thiserror::Error; use tokio::sync::Mutex; use tokio_postgres::{Client, NoTls}; -use crate::decode::codecs::NumericKind; use crate::decode::heap_decoder::ColumnValue; #[derive(Debug, Error)] @@ -40,53 +35,22 @@ crate::atomic_stats! { pub resolved, /// `walshadow_decode_disk` calls returning NULL or absent-extension pub fallback_raw, - pub probes, - pub matches, - /// local decoder text != shadow PG text - pub mismatches, /// SQL / connection errors, single bucket pub errors, } } -/// 1-in-N selection. Lock-free counter so decoder workers share one `Oracle` -/// without serialising. -#[derive(Debug)] -pub struct Sampler { - rate: u32, - counter: AtomicU64, -} - -impl Sampler { - pub fn new(rate: u32) -> Self { - Self { - rate, - counter: AtomicU64::new(0), - } - } - - /// `rate == 0` disables sampling - pub fn pick(&self) -> bool { - if self.rate == 0 { - return false; - } - let n = self.counter.fetch_add(1, Ordering::Relaxed); - n.is_multiple_of(self.rate as u64) - } -} - pub struct Oracle { client: Mutex>, conninfo: String, has_extension: AtomicBool, pub stats: Arc, - pub sampler: Sampler, } 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, sample_rate: u32) -> Result { + pub async fn connect(conninfo: &str) -> Result { let (client, connection) = tokio_postgres::connect(conninfo, NoTls) .await .map_err(OracleError::Connect)?; @@ -99,7 +63,6 @@ impl Oracle { conninfo: conninfo.to_owned(), has_extension: AtomicBool::new(has_ext), stats: Arc::new(OracleStats::default()), - sampler: Sampler::new(sample_rate), }) } @@ -167,59 +130,6 @@ impl Oracle { } } } - - /// Cross-check a locally-decoded Tier 3 value against shadow PG's - /// `typoutput`. Fires only on a sampler hit. Return value informational. - pub async fn validate(&self, type_oid: u32, raw: &[u8], local_text: &str) -> bool { - if !self.sampler.pick() { - return false; - } - let sql = match type_oid { - crate::schema::NUMERICOID - | crate::schema::INETOID - | crate::schema::CIDROID - | crate::schema::INTERVALOID - if self.has_extension() => - { - // walshadow_decode_disk reconstructs the Datum then calls - // typoutput, same path the resolver uses - "SELECT walshadow_decode_disk($1::oid, $2::bytea)" - } - _ => return false, - }; - let typoid_param: u32 = type_oid; - let mut attempt = 0u8; - let res = loop { - let row = { - let mut guard = self.client.lock().await; - let Some(client) = guard.as_mut() else { - return false; - }; - client.query_one(sql, &[&typoid_param, &raw]).await - }; - match row { - Ok(r) => break Some(r), - Err(e) if attempt == 0 && e.is_closed() => { - attempt = 1; - let _ = self.reconnect().await; - } - Err(_) => break None, - } - }; - self.stats.probes.fetch_add(1, Ordering::Relaxed); - let Some(r) = res else { - self.stats.errors.fetch_add(1, Ordering::Relaxed); - return true; - }; - let pg_text: Option = r.try_get(0).ok(); - let matched = pg_text.as_deref() == Some(local_text); - if matched { - self.stats.matches.fetch_add(1, Ordering::Relaxed); - } else { - self.stats.mismatches.fetch_add(1, Ordering::Relaxed); - } - true - } } async fn probe_extension(client: &Client) -> Result { @@ -249,38 +159,13 @@ pub async fn resolve_pending_tuple(oracle: &Oracle, columns: &mut [Option]) { - for col in columns.iter().flatten() { - match col { - ColumnValue::Numeric(k) => { - let local = match k { - NumericKind::Finite(s) => s.clone(), - NumericKind::NaN => "NaN".into(), - NumericKind::PInf => "Infinity".into(), - NumericKind::NInf => "-Infinity".into(), - }; - // No raw bytes here, validator needs them; skip. Its primary - // value is jsonb/array (PgPending), where raw bytes are present - let _ = local; - } - ColumnValue::PgPending { type_oid, raw } => { - let _ = oracle.validate(*type_oid, raw, "").await; - } - _ => {} - } - } -} - impl OracleStats { pub fn summary(&self) -> String { use std::fmt::Write as _; let ld = |a: &AtomicU64| a.load(Ordering::Relaxed); let mut s = format!("oracle resolved={}", ld(&self.resolved)); - let pairs: [(&str, u64); 5] = [ + let pairs: [(&str, u64); 2] = [ ("fallback", ld(&self.fallback_raw)), - ("probes", ld(&self.probes)), - ("match", ld(&self.matches)), - ("mismatch", ld(&self.mismatches)), ("err", ld(&self.errors)), ]; for (label, n) in pairs { @@ -295,13 +180,9 @@ 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, - sample_rate: u32, - budget: Duration, -) -> Result { +pub async fn connect_with_budget(conninfo: &str, budget: Duration) -> Result { let deadline = tokio::time::Instant::now() + budget; - (|| Oracle::connect(conninfo, sample_rate)) + (|| Oracle::connect(conninfo)) .retry( ExponentialBuilder::default() .with_min_delay(Duration::from_millis(100)) @@ -316,38 +197,14 @@ pub async fn connect_with_budget( mod tests { use super::*; - #[test] - fn sampler_off_when_rate_zero() { - let s = Sampler::new(0); - for _ in 0..1000 { - assert!(!s.pick()); - } - } - - #[test] - fn sampler_picks_one_in_n() { - let s = Sampler::new(5); - let mut hits = 0; - for _ in 0..100 { - if s.pick() { - hits += 1; - } - } - assert_eq!(hits, 20); - } - #[test] fn stats_summary_skips_zero_buckets() { let s = OracleStats::default(); s.resolved.store(4, Ordering::Relaxed); - s.probes.store(2, Ordering::Relaxed); - s.matches.store(2, Ordering::Relaxed); + s.errors.store(2, Ordering::Relaxed); let out = s.summary(); assert!(out.contains("resolved=4")); - assert!(out.contains("probes=2")); - assert!(out.contains("match=2")); + assert!(out.contains("err=2")); assert!(!out.contains("fallback")); - assert!(!out.contains("mismatch")); - assert!(!out.contains("err")); } } diff --git a/src/pg.rs b/src/pg.rs index e6c8d5a8..72eb6261 100644 --- a/src/pg.rs +++ b/src/pg.rs @@ -1,7 +1,9 @@ //! Source-PG sidecar SQL helpers shared across sweep/backfill/config paths. -use tokio_postgres::Client; -use tokio_postgres::types::{FromSql, PgLsn, Type}; +use tokio_postgres::types::{FromSql, Oid, PgLsn, Type}; +use tokio_postgres::{Client, Row}; + +use crate::schema::RelAttr; pub use walrus::pg::backup::parse_pg_lsn; @@ -24,6 +26,91 @@ pub fn parse_array_one_element(raw: &str) -> Option { } } +/// Attribute rows shared by catalog fetchers. Physical layout comes straight +/// off pg_attribute: DROP COLUMN zeroes atttypid but preserves +/// attlen/attalign/attbyval/attstorage (PG `src/backend/catalog/heap.c` +/// `RemoveAttributeById`), so pg_type joins LEFT and supplies typname only — +/// an INNER join loses dropped slots and misaligns attnum-1 indexed decode. +pub const ATTR_SQL: &str = "SELECT \ + a.attnum::int2, \ + a.attname::text, \ + a.atttypid::oid, \ + a.atttypmod::int4, \ + a.attnotnull::bool, \ + a.attisdropped::bool, \ + t.typname::text, \ + a.attbyval::bool, \ + a.attlen::int2, \ + a.attalign::text, \ + a.attstorage::text, \ + CASE WHEN a.atthasmissing THEN a.attmissingval::text END \ + FROM pg_attribute a \ + LEFT JOIN pg_type t ON t.oid = a.atttypid \ + WHERE a.attrelid = $1 AND a.attnum >= 1 \ + ORDER BY a.attnum"; + +/// One [`ATTR_SQL`] row before char-field validation. +pub struct RawAttr { + pub attnum: i16, + pub name: String, + pub type_oid: Oid, + pub typmod: i32, + pub not_null: bool, + pub dropped: bool, + /// `None` for dropped slots (atttypid = 0) + pub type_name: Option, + pub type_byval: bool, + pub type_len: i16, + pub type_align: String, + pub type_storage: String, + /// `attmissingval::text` array literal + pub missing: Option, +} + +impl RawAttr { + pub fn from_row(row: &Row) -> Self { + Self { + attnum: row.get(0), + name: row.get(1), + type_oid: row.get(2), + typmod: row.get(3), + not_null: row.get(4), + dropped: row.get(5), + type_name: row.get(6), + type_byval: row.get(7), + type_len: row.get(8), + type_align: row.get(9), + type_storage: row.get(10), + missing: row.get(11), + } + } + + pub fn build(self) -> Result { + Ok(RelAttr { + attnum: self.attnum, + name: self.name, + type_oid: self.type_oid, + typmod: self.typmod, + not_null: self.not_null, + dropped: self.dropped, + type_name: self.type_name.unwrap_or_default(), + type_byval: self.type_byval, + type_len: self.type_len, + type_align: single_char(&self.type_align, "attalign")?, + type_storage: single_char(&self.type_storage, "attstorage")?, + missing_text: self.missing.as_deref().and_then(parse_array_one_element), + }) + } +} + +fn single_char(s: &str, what: &str) -> Result { + let mut it = s.chars(); + match (it.next(), it.next()) { + (Some(c), None) => Ok(c), + _ => Err(format!("expected single char for {what}, got {s:?}")), + } +} + pub fn socket_conninfo(socket_dir: &str, port: u16, user: &str, dbname: &str) -> String { format!("host={socket_dir} port={port} user={user} dbname={dbname}") } @@ -93,4 +180,81 @@ mod tests { assert_eq!(quote_ident("plain"), "\"plain\""); assert_eq!(quote_ident("we\"ird"), "\"we\"\"ird\""); } + + #[test] + fn parse_array_one_element_scalars() { + assert_eq!(parse_array_one_element("{7}").as_deref(), Some("7")); + assert_eq!(parse_array_one_element("{t}").as_deref(), Some("t")); + assert_eq!(parse_array_one_element("{3.14}").as_deref(), Some("3.14"),); + assert_eq!( + parse_array_one_element("{-9223372036854775808}").as_deref(), + Some("-9223372036854775808"), + ); + } + + #[test] + fn parse_array_one_element_quoted_text() { + assert_eq!( + parse_array_one_element("{\"hello\"}").as_deref(), + Some("hello"), + ); + assert_eq!( + parse_array_one_element("{\"hello, world\"}").as_deref(), + Some("hello, world"), + ); + assert_eq!( + parse_array_one_element("{\"a\\\"b\"}").as_deref(), + Some("a\"b"), + ); + } + + #[test] + fn parse_array_one_element_empty_and_null() { + assert!(parse_array_one_element("{}").is_none()); + assert!(parse_array_one_element("{NULL}").is_none()); + assert!(parse_array_one_element("nope").is_none()); + } + + #[test] + fn raw_attr_build_dropped_slot() { + let raw = RawAttr { + attnum: 2, + name: "........pg.dropped.2........".into(), + type_oid: 0, + typmod: -1, + not_null: false, + dropped: true, + type_name: None, + type_byval: false, + type_len: -1, + type_align: "i".into(), + type_storage: "x".into(), + missing: None, + }; + let attr = raw.build().unwrap(); + assert!(attr.dropped); + assert_eq!(attr.type_name, ""); + assert_eq!(attr.type_len, -1); + assert_eq!(attr.type_align, 'i'); + assert_eq!(attr.type_storage, 'x'); + } + + #[test] + fn raw_attr_build_rejects_multichar() { + let raw = RawAttr { + attnum: 1, + name: "id".into(), + type_oid: 23, + typmod: -1, + not_null: true, + dropped: false, + type_name: Some("int4".into()), + type_byval: true, + type_len: 4, + type_align: "ii".into(), + type_storage: "p".into(), + missing: None, + }; + assert!(raw.build().is_err()); + } } diff --git a/src/record.rs b/src/record.rs index 59bf8729..ba42d528 100644 --- a/src/record.rs +++ b/src/record.rs @@ -52,12 +52,32 @@ pub enum Route { ToDecoder, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum CatalogSignal { - #[default] - None, - Invalidate, - InvalidateSweep, +/// One catalog-mutating commit: what descriptor capture needs to enumerate +/// the affected relations. Built by the filter at the commit record. +#[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 + 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) + 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, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AffectedOid { + pub oid: u32, + /// First pg_class touch this xact when decoded pump-side; capture's + /// preferred valid_from after SMGR markers + pub pg_class_touch: Option, } #[derive(Debug, Error)] @@ -74,9 +94,23 @@ pub enum SinkError { pub struct Record<'a> { pub parsed: XLogRecord<'a>, pub source_lsn: u64, + /// PG `XLogReaderState::EndRecPtr`: aligned end of this record, the + /// position `pg_last_wal_replay_lsn()` reports once shadow applies it. + /// `XLOG_SWITCH` advances to segment end. Replay comparisons use this, + /// never the last physical wire byte. + pub next_lsn: u64, pub page_magic: u16, pub route: Route, - pub catalog_signal: CatalogSignal, + /// Commit of a catalog-mutating xact: pump must hold successor-byte + /// publication until shadow replays through `next_lsn` + pub catalog_boundary: bool, + /// Capture input for a catalog boundary; `Some` iff `catalog_boundary` + pub boundary_info: 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 + /// under + pub defer_catalog_decode: bool, } pub trait RecordSink { @@ -160,9 +194,12 @@ impl RecordSink for CollectingRecordSink { self.records.push(Record { parsed: record.parsed.clone().into_owned(), source_lsn: record.source_lsn, + next_lsn: record.next_lsn, page_magic: record.page_magic, route: record.route, - catalog_signal: record.catalog_signal, + catalog_boundary: record.catalog_boundary, + boundary_info: record.boundary_info.clone(), + defer_catalog_decode: record.defer_catalog_decode, }); Ok(()) }) diff --git a/src/runtime_config.rs b/src/runtime_config.rs index 5680b408..e197941b 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -387,6 +387,7 @@ mod tests { rel_node: 20000, }, oid: 20000, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("walshadow", name), kind: 'r', diff --git a/src/schema.rs b/src/schema.rs index 8ff279be..6f4a84cc 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,9 +1,8 @@ //! Postgres relation and schema-change vocabulary use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; -use tokio::sync::mpsc; use tokio_postgres::types::Oid; use walrus::pg::walparser::RelFileNode; @@ -61,6 +60,8 @@ impl std::fmt::Display for RelName { pub struct RelDescriptor { pub rfn: RelFileNode, pub oid: Oid, + /// `pg_class.reltoastrelid`, 0 = no TOAST table + pub toast_oid: Oid, pub namespace_oid: Oid, pub rel_name: RelName, pub kind: char, @@ -182,4 +183,130 @@ pub fn compute_schema_diff(old: &RelDescriptor, new: &RelDescriptor) -> SchemaDi diff } -pub type SchemaEventRx = Arc>>; +#[cfg(test)] +mod tests { + use super::*; + use tokio_postgres::types::Oid; + + fn mk_attr(attnum: i16, name: &str, oid: Oid, not_null: bool) -> RelAttr { + RelAttr { + attnum, + name: name.into(), + type_oid: oid, + typmod: -1, + not_null, + dropped: false, + type_name: "test".into(), + type_byval: true, + type_len: 4, + type_align: 'i', + type_storage: 'p', + missing_text: None, + } + } + + fn mk_desc(oid: Oid, attrs: Vec) -> RelDescriptor { + RelDescriptor { + rfn: RelFileNode { + spc_node: 1663, + db_node: 5, + rel_node: oid, + }, + oid, + toast_oid: 0, + namespace_oid: 2200, + rel_name: RelName::new("public", &format!("t{oid}")), + kind: 'r', + persistence: 'p', + replident: ReplIdent::Default { pk_attnums: None }, + attributes: attrs, + } + } + + #[test] + fn schema_diff_detects_added_columns() { + let old = mk_desc(16400, vec![mk_attr(1, "id", 23, true)]); + let new = mk_desc( + 16400, + vec![mk_attr(1, "id", 23, true), mk_attr(2, "name", 25, false)], + ); + let d = compute_schema_diff(&old, &new); + assert_eq!(d.added_columns.len(), 1); + assert_eq!(d.added_columns[0].attnum, 2); + assert!(d.dropped_columns.is_empty()); + assert!(d.renamed_columns.is_empty()); + assert!(d.type_changes.is_empty()); + } + + #[test] + fn schema_diff_detects_dropped_columns() { + let old = mk_desc( + 16400, + vec![mk_attr(1, "id", 23, true), mk_attr(2, "name", 25, false)], + ); + let new = mk_desc(16400, vec![mk_attr(1, "id", 23, true)]); + let d = compute_schema_diff(&old, &new); + assert_eq!(d.dropped_columns, vec![2]); + assert!(d.added_columns.is_empty()); + } + + #[test] + fn schema_diff_detects_rename_at_same_attnum() { + let old = mk_desc( + 16400, + vec![ + mk_attr(1, "id", 23, true), + mk_attr(2, "old_name", 25, false), + ], + ); + let new = mk_desc( + 16400, + vec![ + mk_attr(1, "id", 23, true), + mk_attr(2, "new_name", 25, false), + ], + ); + let d = compute_schema_diff(&old, &new); + assert_eq!( + d.renamed_columns, + vec![(2, "old_name".into(), "new_name".into())] + ); + assert!(d.added_columns.is_empty()); + assert!(d.dropped_columns.is_empty()); + assert!(d.type_changes.is_empty()); + } + + #[test] + fn schema_diff_detects_type_change_at_same_attnum() { + let old = mk_desc(16400, vec![mk_attr(1, "c", 23, true)]); // int4 + let new = mk_desc(16400, vec![mk_attr(1, "c", 20, true)]); // int8 + let d = compute_schema_diff(&old, &new); + assert_eq!(d.type_changes.len(), 1); + assert_eq!(d.type_changes[0].0, 1); + assert_eq!(d.type_changes[0].1.type_oid, 20); + } + + #[test] + fn schema_diff_skips_pg_dropped_columns_in_old() { + // PG retains DROP COLUMN as attisdropped=true in pg_attribute; diff must + // ignore them, not re-surface as still-present on the new side + let mut a = mk_attr(2, "x", 25, false); + a.dropped = true; + let old = mk_desc(16400, vec![mk_attr(1, "id", 23, true), a]); + let new = mk_desc(16400, vec![mk_attr(1, "id", 23, true)]); + let d = compute_schema_diff(&old, &new); + assert!(d.dropped_columns.is_empty()); + assert!(d.added_columns.is_empty()); + } + + #[test] + fn schema_diff_is_empty_when_shapes_match() { + let a = mk_desc( + 16400, + vec![mk_attr(1, "id", 23, true), mk_attr(2, "name", 25, false)], + ); + let b = a.clone(); + let d = compute_schema_diff(&a, &b); + assert!(d.is_empty()); + } +} diff --git a/src/source/boundary_hold.rs b/src/source/boundary_hold.rs new file mode 100644 index 00000000..02440a88 --- /dev/null +++ b/src/source/boundary_hold.rs @@ -0,0 +1,480 @@ +//! Catalog-boundary publication hold. +//! +//! At the commit of a catalog-mutating xact ([`Record::catalog_boundary`], +//! stamped by the pump classifier) the pump must not publish successor +//! bytes until shadow replays through the commit's `next_lsn` +//! (PG `EndRecPtr`). [`BoundaryHoldSink`] enforces this by blocking inside +//! `on_record`: [`WalStream`](crate::source::wal_stream::WalStream) +//! dispatches wire bytes for record N, then awaits the record sink before +//! framing N+1, so an await here holds every successor byte from both the +//! shadow wire and the archive segment sink. DML-only commits never park. +//! +//! Shadow keeps applying during the hold — the walsender listener task +//! flushes already-queued bytes independently — and reports apply progress +//! via `'r'` standby-status frames. Non-forced walreceiver replies fire +//! only when the flush position advances, so the gate prods with +//! reply-requested keepalives ([`ShadowStreamState::request_status`]) to +//! observe apply advancing at poll cadence rather than +//! `wal_receiver_status_interval`. +//! +//! Waiter is result-bearing: worker death (channel closed / panic), +//! walreceiver loss past the hold timeout, and replay timeout all wake it +//! with `Err`, which poisons the stream and terminates the pump with the +//! root cause. The hold never waits on ClickHouse, committed drains, or +//! queued barrier work — only shadow replay of already-shipped bytes. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use tokio::sync::Mutex; + +use crate::record::{Record, RecordSink, SinkError}; +use crate::source::catalog_capture::CatalogCapture; +use crate::source::queueing_record_sink::QueueingRecordSink; +use crate::source::shadow_stream::ShadowStreamState; + +/// Bounds one publication hold. Must stay well under source PG's +/// `wal_sender_timeout` (default 60s): the pump answers no source +/// keepalives while parked. +pub const DEFAULT_HOLD_TIMEOUT: Duration = Duration::from_secs(30); + +/// Apply-LSN poll cadence. Listener flushes queued frames every ~50ms, so +/// finer polling only spins the lock. +pub const DEFAULT_HOLD_POLL: Duration = Duration::from_millis(20); + +#[derive(Debug, Default)] +pub struct BoundaryHoldStats { + /// Holds released by shadow replay reaching the commit's `next_lsn`. + pub holds: AtomicU64, + /// Holds woken with `Err` (worker death, walreceiver loss, timeout). + pub failures: AtomicU64, + /// Cumulative released-hold duration. + pub hold_nanos: AtomicU64, +} + +impl BoundaryHoldStats { + pub fn hold_seconds_total(&self) -> f64 { + self.hold_nanos.load(Ordering::Relaxed) as f64 / 1e9 + } +} + +#[derive(Debug, Clone, Copy)] +pub struct BoundaryGateConfig { + pub hold_timeout: Duration, + pub poll_interval: Duration, +} + +impl Default for BoundaryGateConfig { + fn default() -> Self { + Self { + hold_timeout: DEFAULT_HOLD_TIMEOUT, + poll_interval: DEFAULT_HOLD_POLL, + } + } +} + +/// Waits for shadow replay to pass a catalog commit's `next_lsn`. +pub struct CatalogBoundaryGate { + state: Arc>, + config: BoundaryGateConfig, + pub stats: Arc, +} + +impl CatalogBoundaryGate { + pub fn new(state: Arc>, config: BoundaryGateConfig) -> Self { + Self { + state, + config, + stats: Arc::new(BoundaryHoldStats::default()), + } + } + + /// Park until shadow's aggregate apply LSN reaches `next_lsn`. + /// `worker_alive` is polled each tick; a dead decoder worker wakes the + /// waiter with `Err` instead of letting the daemon hang out the + /// timeout. Walreceiver loss mid-hold is tolerated until the deadline — + /// a reconnect backfills the in-progress segment and replay resumes — + /// then fails the boundary. + pub async fn hold( + &self, + commit_lsn: u64, + next_lsn: u64, + worker_alive: impl Fn() -> bool, + ) -> Result<(), SinkError> { + let start = Instant::now(); + loop { + let agg = self.state.lock().await.aggregate(); + if agg.min_apply_lsn.is_some_and(|apply| apply >= next_lsn) { + self.stats.holds.fetch_add(1, Ordering::Relaxed); + self.stats + .hold_nanos + .fetch_add(start.elapsed().as_nanos() as u64, Ordering::Relaxed); + // Info: fires at DDL rate, operators read it as the + // hold-latency signal + tracing::info!( + target: "walshadow::boundary_hold", + commit_lsn = format_args!("{commit_lsn:#X}"), + next_lsn = format_args!("{next_lsn:#X}"), + held = ?start.elapsed(), + "catalog boundary released", + ); + return Ok(()); + } + if !worker_alive() { + return self.fail(format!( + "decoder worker terminated during catalog boundary hold at {commit_lsn:#X}" + )); + } + if start.elapsed() >= self.config.hold_timeout { + return self.fail(format!( + "catalog boundary hold at {commit_lsn:#X} timed out after {:?}: \ + shadow apply {:?} < {next_lsn:#X} ({} walreceiver connection(s))", + self.config.hold_timeout, agg.min_apply_lsn, agg.active_connections, + )); + } + self.state.lock().await.request_status(); + tokio::time::sleep(self.config.poll_interval).await; + } + } + + fn fail(&self, msg: String) -> Result<(), SinkError> { + self.stats.failures.fetch_add(1, Ordering::Relaxed); + Err(SinkError::Other(msg)) + } +} + +/// [`QueueingRecordSink`] wrapper enacting the hold. At a catalog boundary: +/// force-flush the pump-side batch (predecessors must not strand in the +/// accumulator while the pump parks — and the flush surfaces any parked +/// worker error first), park in [`CatalogBoundaryGate::hold`], capture +/// 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. +pub struct BoundaryHoldSink { + pub inner: QueueingRecordSink, + pub gate: CatalogBoundaryGate, + /// `None` = hold-only (tests / capture-less harnesses) + pub capture: Option, +} + +impl BoundaryHoldSink { + pub fn new(inner: QueueingRecordSink, gate: CatalogBoundaryGate) -> Self { + Self { + inner, + gate, + capture: None, + } + } + + pub fn with_capture(mut self, capture: CatalogCapture) -> Self { + self.capture = Some(capture); + self + } + + pub fn in_flight(&self) -> u64 { + self.inner.in_flight() + } + + pub fn processed(&self) -> u64 { + self.inner.processed() + } + + pub async fn flush(&mut self) -> Result<(), SinkError> { + self.inner.flush().await + } + + pub async fn close(self) -> Result<(), SinkError> { + self.inner.close().await + } +} + +impl RecordSink for BoundaryHoldSink { + fn on_record<'a>( + &'a mut self, + record: &'a Record<'a>, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + if !record.catalog_boundary { + 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; + if let Err(hold_err) = self + .gate + .hold(record.source_lsn, record.next_lsn, || inner.worker_alive()) + .await + { + // Prefer the worker's parked root cause over the generic + // hold error (empty-buffer flush only drains the err slot) + self.inner.flush().await?; + return Err(hold_err); + } + if let (Some(capture), Some(info)) = (&self.capture, &record.boundary_info) { + capture + .capture_boundary(info, record.source_lsn, record.next_lsn) + .await?; + } + self.inner.on_record(record).await?; + // Ship the commit immediately: the drain (and its CH effects) + // must not wait out the accumulator + self.inner.flush().await + }) + } + + fn on_idle<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + self.inner.on_idle() + } + + fn on_close<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + self.inner.on_close() + } + + fn on_idle_advance<'a>( + &'a mut self, + lsn: u64, + ) -> Pin> + Send + 'a>> { + self.inner.on_idle_advance(lsn) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::record::CountingRecordSink; + + fn gate_with( + state: Arc>, + hold_timeout: Duration, + ) -> CatalogBoundaryGate { + CatalogBoundaryGate::new( + state, + BoundaryGateConfig { + hold_timeout, + poll_interval: Duration::from_millis(1), + }, + ) + } + + fn state() -> Arc> { + Arc::new(Mutex::new(ShadowStreamState::new( + 1, + "sys".into(), + 0x1000, + 1024 * 1024, + ))) + } + + #[tokio::test] + async fn hold_releases_when_apply_reaches_exact_next_lsn() { + let s = state(); + let id = s.lock().await.register_connection(0x1000); + let gate = gate_with(s.clone(), Duration::from_secs(5)); + let waiter = tokio::spawn({ + let s = s.clone(); + async move { + tokio::time::sleep(Duration::from_millis(20)).await; + // apply == next_lsn exactly must release (replay reports + // EndRecPtr, not last wire byte) + s.lock().await.observe_status(id, 0x2000, 0x2000, 0x2000); + } + }); + gate.hold(0x1F00, 0x2000, || true).await.expect("released"); + waiter.await.unwrap(); + assert_eq!(gate.stats.holds.load(Ordering::Relaxed), 1); + assert_eq!(gate.stats.failures.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn hold_prods_walreceiver_with_reply_requested_keepalive() { + let s = state(); + let id = s.lock().await.register_connection(0x1000); + let gate = gate_with(s.clone(), Duration::from_secs(5)); + let prodded = tokio::spawn({ + let s = s.clone(); + async move { + loop { + let drained = s.lock().await.drain_send_queue(id); + if let Some(bytes) = drained { + // 'd' + u32 len + 'k' + wal_end(8) + time(8) + reply(1) + assert_eq!(bytes[5], b'k'); + assert_eq!(*bytes.last().unwrap(), 1, "reply requested"); + s.lock().await.observe_status(id, 0x3000, 0x3000, 0x3000); + return; + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + } + }); + gate.hold(0x2F00, 0x3000, || true).await.expect("released"); + prodded.await.unwrap(); + } + + #[tokio::test] + async fn hold_times_out_without_apply_progress() { + let s = state(); + s.lock().await.register_connection(0x1000); + let gate = gate_with(s.clone(), Duration::from_millis(20)); + let err = gate + .hold(0x1F00, 0x2000, || true) + .await + .expect_err("must time out"); + assert!(err.to_string().contains("timed out"), "{err}"); + assert_eq!(gate.stats.failures.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn hold_times_out_with_no_walreceiver() { + // No connection at all: min_apply_lsn stays None; reconnect never + // comes, deadline fails the boundary + let gate = gate_with(state(), Duration::from_millis(20)); + let err = gate + .hold(0x1F00, 0x2000, || true) + .await + .expect_err("must time out"); + assert!(err.to_string().contains("0 walreceiver"), "{err}"); + } + + #[tokio::test] + async fn hold_releases_after_mid_hold_reconnect() { + // Walreceiver absent when the hold starts; a late attach + status + // must still release within the deadline + let s = state(); + let gate = gate_with(s.clone(), Duration::from_secs(5)); + let attach = tokio::spawn({ + let s = s.clone(); + async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let id = s.lock().await.register_connection(0x1000); + s.lock().await.observe_status(id, 0x2000, 0x2000, 0x2000); + } + }); + gate.hold(0x1F00, 0x2000, || true).await.expect("released"); + attach.await.unwrap(); + } + + #[tokio::test] + async fn dead_worker_wakes_waiter_with_err() { + let s = state(); + s.lock().await.register_connection(0x1000); + let gate = gate_with(s, Duration::from_secs(30)); + let err = gate + .hold(0x1F00, 0x2000, || false) + .await + .expect_err("dead worker must fail the hold"); + assert!(err.to_string().contains("worker terminated"), "{err}"); + assert_eq!(gate.stats.failures.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn sink_parks_only_at_catalog_boundary() { + // DML-only records (catalog_boundary = false) pass straight + // through with no shadow connection and no timeout + let s = state(); + let q = QueueingRecordSink::spawn(CountingRecordSink::default(), 4, 16, None); + let gate = gate_with(s, Duration::from_millis(10)); + let mut sink = BoundaryHoldSink::new(q, gate); + let rec = Record { + source_lsn: 0x1100, + next_lsn: 0x1140, + ..Default::default() + }; + for _ in 0..8 { + sink.on_record(&rec).await.expect("no park"); + } + 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 + // commit strands in the pump-side buffer while the pump parks + let s = state(); + let id = s.lock().await.register_connection(0x1000); + let counter = Arc::new(std::sync::Mutex::new(0u64)); + struct Count(Arc>); + impl RecordSink for Count { + fn on_record<'a>( + &'a mut self, + _r: &'a Record<'a>, + ) -> Pin> + Send + 'a>> { + let c = self.0.clone(); + Box::pin(async move { + *c.lock().unwrap() += 1; + Ok(()) + }) + } + } + let q = QueueingRecordSink::spawn(Count(counter.clone()), 64, 1024, None); + let gate = gate_with(s.clone(), Duration::from_secs(5)); + let mut sink = BoundaryHoldSink::new(q, gate); + let releaser = tokio::spawn({ + let s = s.clone(); + async move { + tokio::time::sleep(Duration::from_millis(20)).await; + s.lock().await.observe_status(id, 0x2000, 0x2000, 0x2000); + } + }); + let rec = Record { + source_lsn: 0x1F00, + next_lsn: 0x2000, + catalog_boundary: true, + ..Default::default() + }; + sink.on_record(&rec).await.expect("boundary releases"); + releaser.await.unwrap(); + // Forced flush shipped the sub-batch-size buffer to the worker + for _ in 0..100 { + if *counter.lock().unwrap() == 1 { + break; + } + tokio::task::yield_now().await; + } + assert_eq!(*counter.lock().unwrap(), 1, "commit reached the worker"); + sink.close().await.expect("close"); + } + + #[tokio::test] + async fn sink_boundary_surfaces_parked_worker_error_over_hold_error() { + struct Fail; + impl RecordSink for Fail { + fn on_record<'a>( + &'a mut self, + _r: &'a Record<'a>, + ) -> Pin> + Send + 'a>> { + Box::pin(async { Err(SinkError::Other("worker boom".into())) }) + } + } + let s = state(); + s.lock().await.register_connection(0x1000); + let q = QueueingRecordSink::spawn(Fail, 1, 4, None); + let gate = gate_with(s, Duration::from_secs(30)); + let mut sink = BoundaryHoldSink::new(q, gate); + // Predecessor record ships (batch_size 1) and kills the worker; + // the boundary's pre-hold flush (or the hold's worker_alive wake) + // must surface that root cause, never the generic hold error + let dml = Record { + source_lsn: 0x1E00, + next_lsn: 0x1F00, + ..Default::default() + }; + let _ = sink.on_record(&dml).await; + let rec = Record { + source_lsn: 0x1F00, + next_lsn: 0x2000, + catalog_boundary: true, + ..Default::default() + }; + let err = sink.on_record(&rec).await.expect_err("must fail"); + assert!(err.to_string().contains("boom"), "{err}"); + } +} diff --git a/src/source/catalog_capture.rs b/src/source/catalog_capture.rs new file mode 100644 index 00000000..9c3deaba --- /dev/null +++ b/src/source/catalog_capture.rs @@ -0,0 +1,546 @@ +//! Descriptor capture at catalog-commit boundaries. +//! +//! Runs inside the pump's publication hold, after shadow applies through the +//! boundary's `next_lsn` and before the commit record forwards to the +//! worker: capture observes exactly the commit's catalog state, its batch is +//! durable before any successor byte publishes, and drain finds events +//! already attached to the xact. +//! +//! Replay-from-log first: a boundary whose batch is already stored derives +//! its events from the stored entries against each oid's historical +//! predecessor — no SQL, deterministic across restarts. Boundaries at or +//! below the seed's `covered_through` are baked into the seed snapshot and +//! skip entirely. A miss queries shadow; the returned replay position must +//! equal `next_lsn` (nothing past the commit has published during the hold), +//! anything else means the log lost coverage — fatal. +//! +//! valid_from bias-early: a descriptor is a backward-compatible reader of +//! older tuples, never the reverse. Rotated filenode → the rfn's +//! `XLOG_SMGR_CREATE` marker (before any page write); in-place change → the +//! oid's first pg_class touch in the xact; fallback the xact tree's first +//! catalog touch. Dropped tombstones at `next_lsn`. +//! +//! Bias-early holds when the final descriptor provably reads the whole +//! dirty interval, and when the only drift is one `catalog::compat` calls +//! benign — declared shape changed, every byte reads the same. A physically +//! unproven in-place transition publishes an `Ambiguity` over +//! `[first_touch, next_lsn)` instead and lands its `Present` at `next_lsn`: +//! post-commit rows decode, interval rows fail closed per record at the +//! drain ([`crate::xact::xact_buffer::resolve_stash`]). One interval per +//! identity key (rfn, oid) so both lookup paths see the same fence. +//! 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. + +use std::collections::HashMap; +use std::sync::Arc; + +use tokio_postgres::types::Oid; +use walrus::pg::walparser::RelFileNode; + +use crate::catalog::compat::Incompat; +use crate::catalog::desc_log::{ + Ambiguity, AmbiguityReason, AmbiguityScope, BatchRecord, DescriptorLog, LogEntry, LogValue, + ObservationKind, RelationObservation, +}; +use crate::catalog::shadow_catalog::ShadowCatalog; +use crate::filter::SmgrMarkers; +use crate::record::{BoundaryInfo, SinkError}; +use crate::schema::{RelDescriptor, SchemaEvent, compute_schema_diff}; +use crate::xact::xact_buffer::XactBuffer; + +crate::atomic_stats! { + pub struct CaptureStats { + /// Boundaries captured via shadow SQL + pub sql_captures, + /// Boundaries replayed from stored batches + pub log_replays, + /// Boundaries at or below covered_through + pub skipped_covered, + /// Descriptors fetched across SQL captures + pub rels_captured, + /// Capture-all boundaries (whole-relcache inval or unenumerated + /// catalog write) + pub capture_all_runs, + pub events_added, + pub events_changed, + pub events_dropped, + /// Ambiguity intervals published for unproven in-place changes + pub ambiguities_published, + /// Capture duration, nanos (inside the boundary hold) + pub capture_nanos, + } +} + +pub struct CatalogCapture { + log: Arc, + catalog: Arc>, + buffer: Arc>, + markers: Arc>, + stats: Arc, +} + +/// One derived schema event keyed at its drain LSN +struct PendingEvent { + lsn: u64, + event: SchemaEvent, +} + +impl CatalogCapture { + pub fn new( + log: Arc, + catalog: Arc>, + buffer: Arc>, + markers: Arc>, + ) -> Self { + Self { + log, + catalog, + buffer, + markers, + stats: Arc::new(CaptureStats::default()), + } + } + + pub fn stats_handle(&self) -> Arc { + self.stats.clone() + } + + pub async fn capture_boundary( + &self, + info: &BoundaryInfo, + commit_lsn: u64, + next_lsn: u64, + ) -> Result<(), SinkError> { + use std::sync::atomic::Ordering::Relaxed; + let start = std::time::Instant::now(); + if next_lsn <= self.log.covered_through() { + self.stats.skipped_covered.fetch_add(1, Relaxed); + return Ok(()); + } + let events = if let Some(batch) = self.log.batch_at(next_lsn) { + self.stats.log_replays.fetch_add(1, Relaxed); + self.replay_events(&batch) + } else { + self.sql_capture(info, commit_lsn, next_lsn).await? + }; + if !events.is_empty() { + let mut buf = self.buffer.lock().await; + for pe in events { + match &pe.event { + SchemaEvent::Added { .. } => self.stats.events_added.fetch_add(1, Relaxed), + SchemaEvent::Changed { .. } => self.stats.events_changed.fetch_add(1, Relaxed), + SchemaEvent::Dropped { .. } => self.stats.events_dropped.fetch_add(1, Relaxed), + }; + buf.on_schema_event(info.drain_xid, pe.lsn, pe.event); + } + } + self.stats + .capture_nanos + .fetch_add(start.elapsed().as_nanos() as u64, Relaxed); + 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). + fn replay_events(&self, batch: &BatchRecord) -> Vec { + let mut out = Vec::new(); + for entry in &batch.entries { + 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()), + _ => None, + }); + match &entry.value { + LogValue::Present(desc) => { + if let Some(event) = diff_event(pred_desc.as_deref(), desc) { + out.push(PendingEvent { + lsn: entry.valid_from, + event, + }); + } + } + LogValue::Dropped => { + if let Some(old) = pred_desc { + out.push(PendingEvent { + lsn: entry.valid_from, + event: SchemaEvent::Dropped { + oid: entry.oid, + rel_name: old.rel_name.clone(), + }, + }); + } + } + LogValue::Retired => {} + } + } + out + } + + async fn sql_capture( + &self, + info: &BoundaryInfo, + commit_lsn: u64, + next_lsn: u64, + ) -> Result, SinkError> { + use std::sync::atomic::Ordering::Relaxed; + self.stats.sql_captures.fetch_add(1, Relaxed); + let (replay_lsn, descs) = { + let mut cat = self.catalog.lock().await; + if info.capture_all { + self.stats.capture_all_runs.fetch_add(1, Relaxed); + cat.fetch_all_descriptors().await + } else { + let oids: Vec = info.oids.iter().map(|a| a.oid).collect(); + cat.fetch_descriptors_batch(&oids).await + } + } + .map_err(|e| SinkError::Other(format!("descriptor capture at {commit_lsn:#X}: {e}")))?; + // Hold guarantees apply >= next_lsn; nothing past the commit has + // published, so equality is the only sane reading. Ahead = this + // boundary replayed into shadow without a stored batch: the log + // lost coverage (wiped/foreign spill dir), decode would misread + if replay_lsn != next_lsn { + return Err(SinkError::Other(format!( + "shadow replay {replay_lsn:#X} != boundary next_lsn {next_lsn:#X}: \ + descriptor log lost coverage; re-bootstrap or --ignore-cursor", + ))); + } + self.stats + .rels_captured + .fetch_add(descs.len() as u64, Relaxed); + + let fetched: HashMap = descs + .into_iter() + .filter(|d| matches!(d.kind, 'r' | 'p' | 'm' | 't')) + .map(|d| (d.oid, d)) + .collect(); + // Tombstone scope: targeted capture checks its own oid list; + // capture-all diffs the log's whole Present set + let mut expected: Vec = if info.capture_all { + let mut all = self.log.present_oids(); + all.extend(fetched.keys().copied()); + all.sort_unstable(); + all.dedup(); + all + } else { + let mut oids: Vec = info.oids.iter().map(|a| a.oid).collect(); + oids.extend(fetched.keys().copied()); + oids.sort_unstable(); + oids.dedup(); + oids + }; + // Deterministic entry order within the batch + expected.sort_unstable(); + + let pg_class_touch: HashMap = info + .oids + .iter() + .filter_map(|a| a.pg_class_touch.map(|l| (a.oid, l))) + .collect(); + + // Evidence: what the boundary knew, so replay reproduces the + // verdict without reinferring from current catalog. Sorted for + // deterministic encoding (info.oids order is map-derived) + let mut observations: Vec = info + .oids + .iter() + .map(|a| RelationObservation { + oid: Some(a.oid), + rfn: None, + first_touch_lsn: a.pg_class_touch.unwrap_or(info.tree_first_touch), + smgr_create_lsn: None, + kind: ObservationKind::AffectedOid, + }) + .collect(); + if info.capture_all { + observations.push(RelationObservation { + oid: None, + rfn: None, + first_touch_lsn: info.tree_first_touch, + smgr_create_lsn: None, + kind: ObservationKind::FullScan, + }); + } + + 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 { + LogValue::Present(d) => Some(d.clone()), + _ => None, + }); + match fetched.get(&oid) { + Some(desc) => { + // Full physical identity: SET TABLESPACE changes spc + // alongside rel_node, and rel_node reuse across + // tablespaces must not read as "same filenode" + let rotated = pred_desc.as_ref().is_some_and(|old| old.rfn != desc.rfn); + let fresh = pred_desc.is_none(); + // New generation: rows cannot precede the smgr create, + // the marker is an exact lower bound; in-place keeps + // the pg_class-touch bias-early bound + let marker = if rotated || fresh { + self.marker_for(desc.rfn) + } else { + None + }; + let first_touch = marker + .or_else(|| pg_class_touch.get(&oid).copied()) + .unwrap_or(info.tree_first_touch); + if let Some(m) = marker { + observations.push(RelationObservation { + oid: Some(oid), + rfn: Some(desc.rfn), + first_touch_lsn: first_touch, + smgr_create_lsn: Some(m), + kind: ObservationKind::SmgrCreate, + }); + } + if rotated && let Some(old) = &pred_desc { + entries.push(Arc::new(LogEntry { + valid_from: first_touch, + oid, + rfn: old.rfn, + value: LogValue::Retired, + })); + } + let changed = pred_desc.as_deref() != Some(desc); + if changed { + // 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); + valid_from = from; + if let Some(why) = incompat.filter(|i| !i.is_physical()) { + tracing::debug!( + target: "walshadow::desc_log", + oid, + rel = %desc.rel_name, + why = why.why(), + "in-place change reads identically, bias-early kept", + ); + } + if !published.is_empty() { + tracing::warn!( + target: "walshadow::desc_log", + oid, + rel = %desc.rel_name, + from = format_args!("{first_touch:#X}"), + through = format_args!("{next_lsn:#X}"), + why = incompat.map_or("", |i| i.why()), + "in-place change not provably decodable, ambiguity published", + ); + // One verdict, one count: siblings name the + // same event under different keys + self.stats.ambiguities_published.fetch_add(1, Relaxed); + ambiguities.extend(published.into_iter().map(Arc::new)); + } + } + let desc = Arc::new(desc.clone()); + entries.push(Arc::new(LogEntry { + valid_from, + oid, + rfn: desc.rfn, + value: LogValue::Present(desc.clone()), + })); + if let Some(event) = diff_event(pred_desc.as_deref(), &desc) { + events.push(PendingEvent { + lsn: valid_from, + event, + }); + } + } + } + None => { + let Some(old) = pred_desc else { continue }; + entries.push(Arc::new(LogEntry { + valid_from: next_lsn, + oid, + rfn: old.rfn, + value: LogValue::Dropped, + })); + events.push(PendingEvent { + lsn: next_lsn, + event: SchemaEvent::Dropped { + oid, + rel_name: old.rel_name.clone(), + }, + }); + } + } + } + observations.sort_unstable_by_key(|o| { + ( + o.kind as u8, + o.oid, + o.rfn.map(|r| (r.spc_node, r.db_node, r.rel_node)), + o.first_touch_lsn, + ) + }); + // Zero-entry stub still appends: boot replay must distinguish + // "captured, no shape change" from "never captured" + self.log + .append_batch(BatchRecord { + captured_at: next_lsn, + commit_lsn, + observations, + ambiguities, + entries, + }) + .await + .map_err(|e| SinkError::Other(format!("descriptor log append: {e}")))?; + Ok(events) + } + + /// Markers key physical WAL locators; descriptor rfns are resolved to + /// physical at capture — match on full identity + fn marker_for(&self, rfn: RelFileNode) -> Option { + self.markers.lock().expect("smgr markers poisoned").get(rfn) + } +} + +/// 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 +/// stay unknown (only the first touch is tracked). Half-open end: the final +/// version answers from `next_lsn`, keeping the post-commit descriptor +/// usable over the ambiguous interval. +/// +/// 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 +/// truncate path agree; order is fixed, batch equality and digest are +/// order-sensitive +fn in_place_verdict( + pred: &RelDescriptor, + fin: &RelDescriptor, + oid: Oid, + first_touch: u64, + next_lsn: u64, +) -> (u64, Vec, Option) { + let Err(incompat) = crate::catalog::compat::compatible_reader(pred, fin) else { + return (first_touch, Vec::new(), None); + }; + if !incompat.is_physical() { + return (first_touch, Vec::new(), Some(incompat)); + } + let interval = |scope| Ambiguity { + scope, + from_lsn: first_touch, + through_lsn: next_lsn, + reason: AmbiguityReason::UnknownMutationPosition, + }; + ( + next_lsn, + vec![ + interval(AmbiguityScope::Rfn(fin.rfn)), + interval(AmbiguityScope::Oid(oid)), + ], + Some(incompat), + ) +} + +/// Added / Changed for heap kinds; toast shape changes are internal (chunk +/// layout is fixed), only its Dropped feeds the retire ledger. +fn diff_event(pred: Option<&RelDescriptor>, desc: &Arc) -> Option { + if desc.kind == 't' { + return None; + } + match pred { + None => Some(SchemaEvent::Added { desc: desc.clone() }), + Some(old) => { + let diff = compute_schema_diff(old, desc); + (!diff.is_empty()).then(|| SchemaEvent::Changed { + old: Arc::new(old.clone()), + new: desc.clone(), + diff, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema::{RelAttr, RelName, ReplIdent}; + + fn rel(type_oid: u32, type_len: i16, name: &str) -> RelDescriptor { + RelDescriptor { + rfn: RelFileNode { + spc_node: 1663, + db_node: 5, + rel_node: 7000, + }, + oid: 42, + toast_oid: 0, + namespace_oid: 2200, + rel_name: RelName::new("public", name), + kind: 'r', + persistence: 'p', + replident: ReplIdent::Default { pk_attnums: None }, + attributes: vec![RelAttr { + attnum: 1, + name: "c1".into(), + type_oid, + typmod: -1, + not_null: false, + dropped: false, + type_name: "t".into(), + type_byval: true, + type_len, + type_align: 'i', + type_storage: 'p', + missing_text: None, + }], + } + } + + #[test] + 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); + assert_eq!(from, 100); + assert!(published.is_empty()); + assert_eq!(incompat, None); + } + + #[test] + fn benign_in_place_keeps_bias_early() { + 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); + 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"))); + } + + #[test] + 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); + 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 + let scopes: Vec = published.iter().map(|a| a.scope).collect(); + assert_eq!( + scopes, + vec![AmbiguityScope::Rfn(fin.rfn), AmbiguityScope::Oid(42)] + ); + for amb in &published { + assert_eq!(amb.from_lsn, 100); + assert_eq!(amb.through_lsn, 500); + assert_eq!(amb.reason, AmbiguityReason::UnknownMutationPosition); + } + } +} diff --git a/src/source/manifest.rs b/src/source/manifest.rs index b7ee7b98..b8308d97 100644 --- a/src/source/manifest.rs +++ b/src/source/manifest.rs @@ -65,7 +65,10 @@ use crate::source::wal_stream::WalStream; pub const MANIFEST_FILENAME: &str = "manifest.toml"; /// Bump on any schema change; boot path rejects mismatched versions. -pub const MANIFEST_VERSION: u32 = 1; +// v2: descriptor-log-aware builds. Any v1 spill dir predates the log and +// cannot be resumed against (decode would read uncovered intervals); the +// version gate turns that into a deterministic upgrade failure. +pub const MANIFEST_VERSION: u32 = 2; /// LSN persisted in postgres `pg_lsn` text form (`1A/2B3C4D5E`). #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] diff --git a/src/source/mod.rs b/src/source/mod.rs index a6556562..1e11c6f6 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -1,3 +1,5 @@ +pub mod boundary_hold; +pub mod catalog_capture; pub mod manifest; pub mod queueing_record_sink; pub(super) mod segment; diff --git a/src/source/queueing_record_sink.rs b/src/source/queueing_record_sink.rs index 1025b0e7..27690dd1 100644 --- a/src/source/queueing_record_sink.rs +++ b/src/source/queueing_record_sink.rs @@ -234,6 +234,13 @@ impl QueueingRecordSink { self.processed.load(Ordering::Relaxed) } + /// False once the worker task ended: fatal-error drain closes the + /// channel, a panic drops the receiver. Lets a publication hold detect + /// worker death without shipping a record. + pub fn worker_alive(&self) -> bool { + self.tx.as_ref().is_some_and(|tx| !tx.is_closed()) + } + /// Ship the accumulated buffer without waiting for `batch_size`. /// Pump calls this after each chunk so a quiescent source can't /// strand commits in the pump-side buffer. @@ -335,9 +342,12 @@ impl RecordSink for QueueingRecordSink { self.buf.push(Record { parsed: record.parsed.clone().into_owned(), source_lsn: record.source_lsn, + next_lsn: record.next_lsn, page_magic: record.page_magic, route: record.route, - catalog_signal: record.catalog_signal, + catalog_boundary: record.catalog_boundary, + boundary_info: record.boundary_info.clone(), + defer_catalog_decode: record.defer_catalog_decode, }); if self.buf.len() >= self.batch_size { self.flush_buf().await?; @@ -357,20 +367,19 @@ mod tests { Record { parsed: XLogRecord::default(), source_lsn, - page_magic: 0, route: crate::record::Route::ToShadow, - catalog_signal: crate::record::CatalogSignal::None, + ..Default::default() } } - struct CaptureLsn(Arc>>); + struct CaptureLsn(Arc>>); impl RecordSink for CaptureLsn { fn on_record<'a>( &'a mut self, r: &'a Record<'a>, ) -> Pin> + Send + 'a>> { let sink = self.0.clone(); - let item = (r.source_lsn, r.catalog_signal); + let item = (r.source_lsn, r.boundary_info.is_some()); Box::pin(async move { sink.lock().unwrap().push(item); Ok(()) @@ -380,26 +389,25 @@ mod tests { #[tokio::test] async fn forwards_records_in_order() { - use crate::record::CatalogSignal; let collected = Arc::new(StdMutex::new(Vec::new())); let mut q = QueueingRecordSink::spawn(CaptureLsn(collected.clone()), 2, 8, None); for lsn in [10, 20, 30, 40] { q.on_record(&synth(lsn)).await.expect("send"); } - // `catalog_signal` must survive the clone-to-owned hop: the worker's - // decoder bumps epochs off it (see `catalog_tracker` module doc) + // `boundary_info` must survive the clone-to-owned hop: the worker's + // drain reads it let mut ddl = synth(50); - ddl.catalog_signal = CatalogSignal::Invalidate; + ddl.boundary_info = Some(std::sync::Arc::new(crate::record::BoundaryInfo::default())); q.on_record(&ddl).await.expect("send"); q.close().await.expect("close"); assert_eq!( collected.lock().unwrap().as_slice(), &[ - (10, CatalogSignal::None), - (20, CatalogSignal::None), - (30, CatalogSignal::None), - (40, CatalogSignal::None), - (50, CatalogSignal::Invalidate), + (10, false), + (20, false), + (30, false), + (40, false), + (50, true), ], ); } @@ -432,6 +440,31 @@ mod tests { assert!(matches!(err, SinkError::Other(s) if s.contains("boom"))); } + #[tokio::test] + async fn worker_alive_false_after_worker_error() { + struct Fail; + impl RecordSink for Fail { + fn on_record<'a>( + &'a mut self, + _r: &'a Record<'a>, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { Err(SinkError::Other("boom".into())) }) + } + } + let mut q = QueueingRecordSink::spawn(Fail, 1, 4, None); + assert!(q.worker_alive()); + let _ = q.on_record(&synth(1)).await; + // Fatal path closes the channel; a boundary hold polls this + // without shipping a record + for _ in 0..500 { + if !q.worker_alive() { + return; + } + tokio::task::yield_now().await; + } + panic!("worker_alive stayed true after fatal worker error"); + } + #[tokio::test] async fn close_drains_pending() { let count = Arc::new(StdMutex::new(0u64)); diff --git a/src/source/shadow_stream.rs b/src/source/shadow_stream.rs index 870eadd1..0a633b26 100644 --- a/src/source/shadow_stream.rs +++ b/src/source/shadow_stream.rs @@ -335,6 +335,26 @@ impl ShadowStreamState { c.dispatched_lsn = c.dispatched_lsn.max(new_lsn); } } + + /// Enqueue a reply-requested `'k'` keepalive on every active + /// connection. Shadow's walreceiver answers immediately with fresh + /// flush/apply LSNs — non-forced replies otherwise fire only when the + /// flush position advances or `wal_receiver_status_interval` elapses, + /// so a publication hold waiting on apply progress prods through this. + pub fn request_status(&mut self) { + let server_wal_end = self.server_wal_end; + let ids: Vec = self + .connections + .iter() + .filter(|(_, c)| !c.closing) + .map(|(id, _)| *id) + .collect(); + for id in ids { + let _ = self.enqueue_copy_data_with(id, |out| { + encode_keepalive_frame_into(out, server_wal_end, true); + }); + } + } } pub struct ShadowStreamSink { diff --git a/src/source/wal_stream.rs b/src/source/wal_stream.rs index 6119cbdb..a1604379 100644 --- a/src/source/wal_stream.rs +++ b/src/source/wal_stream.rs @@ -32,7 +32,9 @@ use thiserror::Error; use walrus::pg::wal::segment::SegmentName; -use walrus::pg::walparser::{ParseError, XLogRecord, parse_record_from_bytes}; +use walrus::pg::walparser::{ + ParseError, RmId, X_LOG_RECORD_ALIGNMENT, X_LOG_SWITCH, XLogRecord, parse_record_from_bytes, +}; use crate::filter::manifest::{Entry, FILTER_VERSION, Kind, Manifest}; use crate::filter::rewrite::{RewriteError, noop_replace}; @@ -63,6 +65,12 @@ pub enum WalStreamError { #[source] source: ParseError, }, + #[error("commit payload at offset {offset}: {source}")] + Payload { + offset: usize, + #[source] + source: crate::decode::wal_xact::XactPayloadError, + }, #[error("rewrite record at offset {offset}: {source}")] Rewrite { offset: usize, @@ -238,8 +246,7 @@ impl WalStream { // NOOP below can take `&mut self.walker` without conflict, // and so the decoder reads the original bytes after the // shadow stream is clobbered. - let route; - let catalog_signal; + let verdict; let parsed_for_sink: XLogRecord<'static>; { let parsed = parse_record_from_bytes( @@ -250,11 +257,18 @@ impl WalStream { offset: start_offset, source, })?; - (route, catalog_signal) = self.filter.decide_with_signal(&parsed); + verdict = self + .filter + .decide_record(&parsed, self.current_lsn + start_offset as u64, page_magic) + .map_err(|source| WalStreamError::Payload { + offset: start_offset, + source, + })?; // `rewrite_record` below mutates walker.buf that `parsed` // views; dispatch needs the original parse, not post-rewrite. parsed_for_sink = parsed.into_owned(); } + let route = verdict.route; let kind = match route { Route::ToShadow => Kind::Kept, Route::ToDecoder => Kind::Dropped, @@ -304,12 +318,16 @@ impl WalStream { } let source_lsn = self.current_lsn + start_offset as u64; + let next_lsn = self.end_rec_ptr(&last_range, &parsed_for_sink); let record = Record { parsed: parsed_for_sink, source_lsn, + next_lsn, page_magic, route, - catalog_signal, + catalog_boundary: verdict.catalog_boundary, + boundary_info: verdict.boundary, + defer_catalog_decode: verdict.defer_catalog_decode, }; if let Some(sink) = record_sink.as_deref_mut() { sink.on_record(&record).await?; @@ -422,6 +440,28 @@ impl WalStream { Ok(()) } + /// PG `XLogReaderState::EndRecPtr` for a record whose final byte range + /// is `last_range` (walker-buffer offset + len, base `current_lsn`). + /// + /// Ports xlogreader.c's arithmetic: single-page records advance + /// `RecPtr + MAXALIGN(xl_tot_len)`; page- and segment-spanning records + /// advance `last_page_addr + page_header_size + MAXALIGN(rem_len)` — + /// both collapse to aligning the final range's end, since a + /// continuation range starts at its page's aligned data offset. + /// `XLOG_SWITCH` pretends to extend to segment end. + fn end_rec_ptr(&self, last_range: &(usize, usize), parsed: &XLogRecord) -> u64 { + let (off, len) = *last_range; + let aligned_len = (len + X_LOG_RECORD_ALIGNMENT - 1) & !(X_LOG_RECORD_ALIGNMENT - 1); + let mut end = self.current_lsn + off as u64 + aligned_len as u64; + if parsed.header.resource_manager_id == RmId::Xlog as u8 + && parsed.header.info & 0xF0 == X_LOG_SWITCH + { + end += self.seg_size - 1; + end -= end % self.seg_size; + } + end + } + fn segment_for_lsn(&self, lsn: u64) -> SegmentName { let seg_no = lsn / self.seg_size; let xlog_segs_per_xlog_id = 0x1_0000_0000u64 / self.seg_size; @@ -775,7 +815,7 @@ mod tests { } } - let page = synth_xact_page(); + let page = synth_two_record_page(); let mut ws = WalStream::new(1, SEG, 0).unwrap(); ws.set_bytes_sink(Box::new(SharedCollector( collector_chunks.clone(), @@ -802,7 +842,7 @@ mod tests { assert_eq!(&reconstructed, seg_bytes, "wire bytes match segment bytes"); } - fn synth_xact_page() -> Vec { + fn synth_two_record_page() -> Vec { use walrus::pg::walparser::{ WAL_PAGE_SIZE, X_LOG_RECORD_HEADER_SIZE, XLP_LONG_HEADER, XLP_PAGE_MAGIC_PG15, XLR_BLOCK_ID_DATA_SHORT, @@ -827,8 +867,8 @@ mod tests { v[20..24].copy_from_slice(&crc.to_le_bytes()); v } - let r1 = rec(walrus::pg::walparser::RmId::Xact as u8); - let r2 = rec(walrus::pg::walparser::RmId::Xact as u8); + let r1 = rec(walrus::pg::walparser::RmId::Clog as u8); + let r2 = rec(walrus::pg::walparser::RmId::Clog as u8); let mut page = Vec::with_capacity(PAGE_SIZE); page.extend_from_slice(&XLP_PAGE_MAGIC_PG15.to_le_bytes()); page.extend_from_slice(&XLP_LONG_HEADER.to_le_bytes()); @@ -904,6 +944,324 @@ mod tests { (page0, page1) } + /// General record-bytes builder: optional block ref (no data) + + /// optional short main_data, CRC patched like `synth_xact_page`. + fn raw_rec( + rmid: u8, + info: u8, + xid: u32, + block: Option<(u32, u32, u32)>, + main_data: Option<&[u8]>, + ) -> Vec { + use walrus::pg::walparser::XLR_BLOCK_ID_DATA_SHORT; + let mut v = Vec::new(); + v.extend_from_slice(&0u32.to_le_bytes()); // tot_len backpatched + v.extend_from_slice(&xid.to_le_bytes()); + v.extend_from_slice(&0u64.to_le_bytes()); // prev + v.push(info); + v.push(rmid); + v.push(0); + v.push(0); + v.extend_from_slice(&0u32.to_le_bytes()); // crc backpatched + if let Some((spc, db, rel)) = block { + v.push(0); // block_id 0 + v.push(0); // fork_flags: main fork, no image/data + v.extend_from_slice(&0u16.to_le_bytes()); // data_length + v.extend_from_slice(&spc.to_le_bytes()); + v.extend_from_slice(&db.to_le_bytes()); + v.extend_from_slice(&rel.to_le_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); // block_no + } + if let Some(md) = main_data { + v.push(XLR_BLOCK_ID_DATA_SHORT); + v.push(md.len() as u8); + v.extend_from_slice(md); + } + let total = v.len() as u32; + v[0..4].copy_from_slice(&total.to_le_bytes()); + let crc = crate::filter::rewrite::compute_crc(&v); + v[20..24].copy_from_slice(&crc.to_le_bytes()); + v + } + + fn page_of(records: &[Vec], page_len: usize) -> Vec { + use walrus::pg::walparser::{XLP_LONG_HEADER, XLP_PAGE_MAGIC_PG15}; + let mut page = Vec::with_capacity(page_len); + page.extend_from_slice(&XLP_PAGE_MAGIC_PG15.to_le_bytes()); + page.extend_from_slice(&XLP_LONG_HEADER.to_le_bytes()); + page.extend_from_slice(&1u32.to_le_bytes()); // timeline + page.extend_from_slice(&0u64.to_le_bytes()); // page_address + page.extend_from_slice(&0u32.to_le_bytes()); // remaining_data_len + page.extend_from_slice(&12345u64.to_le_bytes()); // sysid + page.extend_from_slice(&(page_len as u32).to_le_bytes()); // seg_size + page.extend_from_slice(&8192u32.to_le_bytes()); // blcksz + page.extend_from_slice(&[0u8; 4]); // pad to 40 + for r in records { + page.extend_from_slice(r); + let pad = (8 - (page.len() % 8)) % 8; + page.extend(std::iter::repeat_n(0u8, pad)); + } + page.resize(page_len, 0); + page + } + + /// PG EndRecPtr: single-page records advance by MAXALIGN(xl_tot_len); + /// an unaligned length (30 → 32) must not leak into `next_lsn`. + #[tokio::test(flavor = "current_thread")] + async fn next_lsn_advances_by_maxaligned_total_len() { + const SEG: u64 = 8192; + let mut ws = WalStream::new(1, SEG, 0).unwrap(); + let mut rec = CollectingRecordSink::default(); + let mut seg = CollectingSegmentSink::default(); + ws.push(0, &synth_two_record_page(), &mut rec, &mut seg) + .await + .unwrap(); + // synth records are 30 bytes at offsets 40 and 72 + assert_eq!(rec.records[0].source_lsn, 40); + assert_eq!(rec.records[0].next_lsn, 72); + assert_eq!(rec.records[1].source_lsn, 72); + assert_eq!(rec.records[1].next_lsn, 104); + } + + /// Page-spanning record: EndRecPtr = continuation-page data start + + /// MAXALIGN(rem_len), NOT record start + MAXALIGN(total) (page header + /// bytes intervene). + #[tokio::test(flavor = "current_thread")] + async fn next_lsn_cross_page_record_uses_continuation_page_arithmetic() { + use walrus::pg::walparser::{WAL_PAGE_SIZE, X_LOG_RECORD_HEADER_SIZE, XLP_PAGE_MAGIC_PG15}; + const PAGE: usize = WAL_PAGE_SIZE as usize; + const SEG: u64 = 2 * PAGE as u64; // both pages in one segment + // 8200-byte record from page-0 offset 40: 8152 on page 0, 48 on + // page 1 whose data starts at PAGE + 24 (short header) + let total = 8200usize; + let main_data_len = total - X_LOG_RECORD_HEADER_SIZE - 5; + let mut record = Vec::with_capacity(total); + record.extend_from_slice(&(total as u32).to_le_bytes()); + record.extend_from_slice(&0u32.to_le_bytes()); + record.extend_from_slice(&0u64.to_le_bytes()); + record.push(0); + record.push(RmId::Xact as u8); + record.push(0); + record.push(0); + record.extend_from_slice(&0u32.to_le_bytes()); + record.push(walrus::pg::walparser::XLR_BLOCK_ID_DATA_LONG); + record.extend_from_slice(&(main_data_len as u32).to_le_bytes()); + record.resize(total, 0xAA); + + let p0_data = PAGE - 40; + let mut page0 = page_of(&[], PAGE); + page0.truncate(40); + page0.extend_from_slice(&record[..p0_data]); + + let mut page1 = Vec::with_capacity(PAGE); + page1.extend_from_slice(&XLP_PAGE_MAGIC_PG15.to_le_bytes()); + page1.extend_from_slice(&0u16.to_le_bytes()); // short header + page1.extend_from_slice(&1u32.to_le_bytes()); + page1.extend_from_slice(&(PAGE as u64).to_le_bytes()); + page1.extend_from_slice(&((total - p0_data) as u32).to_le_bytes()); // rem_len = 48 + page1.extend_from_slice(&[0u8; 4]); // pad to 24 + page1.extend_from_slice(&record[p0_data..]); + page1.resize(PAGE, 0); + + let mut ws = WalStream::new(1, SEG, 0).unwrap(); + let mut rec = CollectingRecordSink::default(); + let mut seg = CollectingSegmentSink::default(); + ws.push(0, &page0, &mut rec, &mut seg).await.unwrap(); + assert!(rec.records.is_empty()); + ws.push(PAGE as u64, &page1, &mut rec, &mut seg) + .await + .unwrap(); + assert_eq!(rec.records[0].source_lsn, 40); + // PAGE + 24 (continuation data start) + MAXALIGN(48) + assert_eq!(rec.records[0].next_lsn, PAGE as u64 + 24 + 48); + } + + /// Segment-spanning record ending flush on a page boundary: EndRecPtr + /// is exactly the boundary address. + #[tokio::test(flavor = "current_thread")] + async fn next_lsn_segment_spanning_record_ends_on_boundary() { + const SEG: u64 = walrus::pg::walparser::WAL_PAGE_SIZE as u64; + let mut ws = WalStream::new(1, SEG, 0).unwrap(); + let mut rec = CollectingRecordSink::default(); + let mut seg = CollectingSegmentSink::default(); + let (page0, page1) = synth_two_page_spanning_record(); + ws.push(0, &page0, &mut rec, &mut seg).await.unwrap(); + ws.push(SEG, &page1, &mut rec, &mut seg).await.unwrap(); + assert_eq!(rec.records.len(), 1); + assert_eq!(rec.records[0].next_lsn, 2 * SEG); + } + + /// XLOG_SWITCH pretends to extend to segment end. + #[tokio::test(flavor = "current_thread")] + async fn next_lsn_xlog_switch_advances_to_segment_end() { + const SEG: u64 = 8192; + let switch = raw_rec(RmId::Xlog as u8, X_LOG_SWITCH, 0, None, None); + let page = page_of(&[switch], SEG as usize); + let mut ws = WalStream::new(1, SEG, 0).unwrap(); + let mut rec = CollectingRecordSink::default(); + let mut seg = CollectingSegmentSink::default(); + ws.push(0, &page, &mut rec, &mut seg).await.unwrap(); + assert_eq!(rec.records[0].source_lsn, 40); + assert_eq!(rec.records[0].next_lsn, SEG); + } + + /// Bare-DDL commit (catalog write + commit, no replicated rows) parks + /// the pump: successor bytes reach neither the shadow wire nor the + /// segment sink until shadow's apply LSN passes the commit's + /// `next_lsn`; then everything flows and the hold releases exactly once. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn catalog_commit_parks_pump_until_shadow_replay() { + use crate::record::CountingRecordSink; + use crate::source::boundary_hold::{ + BoundaryGateConfig, BoundaryHoldSink, CatalogBoundaryGate, + }; + use crate::source::queueing_record_sink::QueueingRecordSink; + use crate::source::shadow_stream::ShadowStreamState; + use std::sync::Arc; + use std::time::Duration; + use tokio::sync::Mutex; + + const SEG: u64 = 8192; + // A: catalog heap insert (pg_class) xid 7 — 44 bytes at 40 + let a = raw_rec(RmId::Heap as u8, 0x00, 7, Some((1663, 5, 1259)), None); + assert_eq!(a.len(), 44); + // B: commit xid 7 — 34 bytes at 88, EndRecPtr 128 + let b = raw_rec(RmId::Xact as u8, 0x00, 7, None, Some(&0i64.to_le_bytes())); + assert_eq!(b.len(), 34); + // C: user heap insert xid 8 — successor bytes that must hold + let c = raw_rec(RmId::Heap as u8, 0x00, 8, Some((1663, 5, 50000)), None); + let page = page_of(&[a, b, c], SEG as usize); + let (b_wire_end, b_next_lsn, c_wire_end) = (40 + 44 + 4 + 34, 128u64, 128 + 44); + + let state = Arc::new(Mutex::new(ShadowStreamState::new(1, "sys".into(), 0, 1024))); + let conn = state.lock().await.register_connection(0); + + type Chunks = Arc>>; + let chunks: Chunks = Arc::default(); + struct SpanLog(Chunks); + impl RecordBytesSink for SpanLog { + fn on_wire_chunk<'a>( + &'a mut self, + start_lsn: u64, + bytes: &'a [u8], + ) -> Pin> + Send + 'a>> { + self.0.lock().unwrap().push((start_lsn, bytes.len())); + Box::pin(std::future::ready(Ok(()))) + } + } + + let mut ws = WalStream::new(1, SEG, 0).unwrap(); + ws.set_bytes_sink(Box::new(SpanLog(chunks.clone()))); + let q = QueueingRecordSink::spawn(CountingRecordSink::default(), 64, 1024, None); + let gate = CatalogBoundaryGate::new( + state.clone(), + BoundaryGateConfig { + hold_timeout: Duration::from_secs(10), + poll_interval: Duration::from_millis(1), + }, + ); + let stats = gate.stats.clone(); + let mut sink = BoundaryHoldSink::new(q, gate); + let pump = tokio::spawn(async move { + let mut seg = CollectingSegmentSink::default(); + ws.push(0, &page, &mut sink, &mut seg).await.unwrap(); + (sink, seg.segments.len()) + }); + + // Wait for the pump to park: commit B's wire chunk dispatched… + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + let max_end = chunks + .lock() + .unwrap() + .iter() + .map(|(s, l)| s + *l as u64) + .max() + .unwrap_or(0); + if max_end >= b_wire_end as u64 { + break; + } + assert!( + std::time::Instant::now() < deadline, + "commit bytes never reached the wire", + ); + tokio::time::sleep(Duration::from_millis(1)).await; + } + // …and successor C's withheld while apply lags. + tokio::time::sleep(Duration::from_millis(20)).await; + let max_end = chunks + .lock() + .unwrap() + .iter() + .map(|(s, l)| s + *l as u64) + .max() + .unwrap(); + assert_eq!( + max_end, b_wire_end as u64, + "successor bytes published during hold", + ); + assert_eq!(stats.holds.load(std::sync::atomic::Ordering::Relaxed), 0); + + // Shadow replays through the commit's EndRecPtr → release. + state + .lock() + .await + .observe_status(conn, b_next_lsn, b_next_lsn, b_next_lsn); + let (sink, segs_shipped) = pump.await.unwrap(); + assert_eq!(stats.holds.load(std::sync::atomic::Ordering::Relaxed), 1); + assert_eq!(stats.failures.load(std::sync::atomic::Ordering::Relaxed), 0); + let max_end = chunks + .lock() + .unwrap() + .iter() + .map(|(s, l)| s + *l as u64) + .max() + .unwrap(); + assert!( + max_end >= c_wire_end as u64, + "successor flowed after release" + ); + assert_eq!(segs_shipped, 1, "segment shipped only after release"); + sink.close().await.unwrap(); + } + + /// DML-only commit never parks: push completes with no walreceiver + /// attached and no apply progress at all. + #[tokio::test(flavor = "current_thread")] + async fn dml_only_commit_does_not_park() { + use crate::record::CountingRecordSink; + use crate::source::boundary_hold::{ + BoundaryGateConfig, BoundaryHoldSink, CatalogBoundaryGate, + }; + use crate::source::queueing_record_sink::QueueingRecordSink; + use crate::source::shadow_stream::ShadowStreamState; + use std::sync::Arc; + use std::time::Duration; + use tokio::sync::Mutex; + + const SEG: u64 = 8192; + let ins = raw_rec(RmId::Heap as u8, 0x00, 8, Some((1663, 5, 50000)), None); + let commit = raw_rec(RmId::Xact as u8, 0x00, 8, None, Some(&0i64.to_le_bytes())); + let page = page_of(&[ins, commit], SEG as usize); + + let state = Arc::new(Mutex::new(ShadowStreamState::new(1, "sys".into(), 0, 1024))); + let mut ws = WalStream::new(1, SEG, 0).unwrap(); + let q = QueueingRecordSink::spawn(CountingRecordSink::default(), 4, 16, None); + let gate = CatalogBoundaryGate::new(state, BoundaryGateConfig::default()); + let stats = gate.stats.clone(); + let mut sink = BoundaryHoldSink::new(q, gate); + let mut seg = CollectingSegmentSink::default(); + tokio::time::timeout( + Duration::from_secs(5), + ws.push(0, &page, &mut sink, &mut seg), + ) + .await + .expect("DML-only commit must not park") + .unwrap(); + assert_eq!(stats.holds.load(std::sync::atomic::Ordering::Relaxed), 0); + sink.close().await.unwrap(); + } + #[tokio::test(flavor = "current_thread")] async fn shadow_wire_buf_bounded_when_record_straddles_segment() { use crate::source::shadow_stream::{ShadowStreamSink, ShadowStreamState}; diff --git a/src/xact/spill.rs b/src/xact/spill.rs index 62c8e9ca..36aeccd6 100644 --- a/src/xact/spill.rs +++ b/src/xact/spill.rs @@ -15,12 +15,19 @@ //! ```text //! [2 bytes "WS" magic] [u16 LE version] then repeating: //! [u8 tag] [u32 len LE] [body of `len` bytes] -//! tag = 0 → SpillEntry::Heap (body = encoded DecodedHeap) +//! tag = 0 → SpillEntry::Heap (body = u32 dict id + encoded DecodedHeap) //! tag = 1 → SpillEntry::Chunk (body = encoded ToastChunk) //! tag = 2 → SpillEntry::ToastDelete (body = encoded ToastDelete) //! tag = 3 → SpillEntry::Raw (body = encoded RawRecord) +//! tag = 4 → descriptor dictionary (body = u64 valid_from + descriptor) //! ``` //! +//! Tag 4 never surfaces as an entry: each distinct `(rfn, valid_from)` +//! descriptor writes once before its first referencing heap, so spilled +//! [`DescribedHeap`]s rehydrate the exact attached descriptor without a +//! live log lookup (file stays self-contained; capture landing mid-spill +//! can't reinterpret rows) +//! //! Version bumps on any body-encoding change. Files don't survive a restart //! (resume contract wipes the dir), so magic + version is self-check honesty: //! a mid-restart format mismatch surfaces as [`SpillError::Format`] not a @@ -50,13 +57,17 @@ use tokio::fs::{File, OpenOptions}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use walrus::pg::walparser::RelFileNode; -use crate::decode::heap_decoder::{ColumnValue, DecodedHeap, DecodedTuple, HeapOp, ToastPointer}; +use crate::catalog::desc_log::{decode_descriptor_bytes, encode_descriptor_bytes}; +use crate::decode::heap_decoder::{ + ColumnValue, DecodedHeap, DecodedTuple, DescribedHeap, HeapOp, ToastPointer, +}; +use crate::schema::RelDescriptor; /// Heap tuples + TOAST chunks share the file: both flush at commit drain, /// WAL-aligned ordering keeps the drain a single linear read #[derive(Debug, Clone, PartialEq)] pub enum SpillEntry { - Heap(Box), + Heap(Box), Chunk(ToastChunk), ToastDelete(ToastDelete), /// Undecoded record on a filenode invisible at record time (same-xact @@ -72,6 +83,10 @@ pub enum SpillEntry { /// `src/backend/access/heap/heapam.c`) still decodes. #[derive(Debug, Clone, PartialEq)] pub struct RawRecord { + /// Original writer xid from the record header — a subxact's own xid, + /// surviving merge into the top-level stream, so raw-decoded rows keep + /// the same `_xid` the live decode path would have emitted + pub xid: u32, pub rm: u8, pub info: u8, pub source_lsn: u64, @@ -105,6 +120,7 @@ impl RawRecord { page_magic: u16, ) -> Self { Self { + xid: parsed.header.xact_id, rm: parsed.header.resource_manager_id, info: parsed.header.info, source_lsn, @@ -132,8 +148,9 @@ impl RawRecord { } } - /// Rebuild a borrowed record for the shared heap decoder. `xact_id` / - /// CRC are irrelevant post-commit and stay zero. + /// Rebuild a borrowed record for the shared heap decoder. `xact_id` is + /// restored so decoded heaps keep the writer's `_xid`; CRC is + /// irrelevant post-commit and stays zero. pub fn to_xlog_record(&self) -> walrus::pg::walparser::XLogRecord<'_> { use walrus::pg::walparser::{ BlockLocation, XLogRecord, XLogRecordBlock, XLogRecordBlockHeader, @@ -141,6 +158,7 @@ impl RawRecord { }; XLogRecord { header: XLogRecordHeader { + xact_id: self.xid, info: self.info, resource_manager_id: self.rm, ..Default::default() @@ -179,6 +197,27 @@ impl RawRecord { } } + /// Drop block images that decode can never read: a block carrying its + /// own data decodes from that data, and the FPI-restore path + /// (`decode_image_insert`, toast rewrite) only ever runs for a block + /// with an image and no data. Clearing the flag too keeps the record + /// self-consistent, so operation policy still reads it as data-carrying. + /// Images are up to 8 KiB each; a dirty xact's COPY otherwise spills its + /// whole WAL volume + pub fn drop_redundant_images(&mut self) { + for b in &mut self.blocks { + if b.data_length == 0 || b.image_length == 0 { + continue; + } + b.fork_flags &= !walrus::pg::walparser::BKP_BLOCK_HAS_IMAGE; + b.image_length = 0; + b.hole_offset = 0; + b.hole_length = 0; + b.bimg_info = 0; + b.image = Vec::new(); + } + } + /// Target filenode: block 0's relation, matching decoder convention pub fn rfn(&self) -> Option { self.blocks.first().map(|b| RelFileNode { @@ -238,11 +277,13 @@ pub type Result = std::result::Result; /// ASCII for `xxd`-friendly debug pub const SPILL_MAGIC: [u8; 2] = *b"WS"; -/// v2 added `HeapOp::Truncate` tag-4 body encoding. v3 added chunk TIDs and -/// `ToastDelete` entries. v4 added tag-3 `Raw` stashed records. `DrainEntry` -/// events (Catalog, ToastBarrier, and Config/Signal per the runtime-config -/// plan) are drain-time, never spilled, so they don't touch this format -pub const SPILL_VERSION: u16 = 4; +/// v2 added `HeapOp::Truncate` body encoding. v3 added chunk TIDs and +/// `ToastDelete` entries. v4 added tag-3 `Raw` stashed records. v5 added +/// tag-4 descriptor dictionary (Heap bodies reference descriptors by dict +/// id). v6 added writer xid to heap + raw bodies. `DrainEntry` events +/// (Catalog, ToastBarrier, Config/Signal) are drain-time, never spilled, +/// so they don't touch this format +pub const SPILL_VERSION: u16 = 6; pub struct SpillStore { dir: PathBuf, @@ -277,6 +318,7 @@ impl SpillStore { file, path, byte_count: header.len() as u64, + dict: std::collections::HashMap::new(), }) } @@ -457,6 +499,8 @@ pub struct SpillWriter { file: File, path: PathBuf, byte_count: u64, + /// Descriptor dictionary ids by log identity, assigned in first-use order + dict: std::collections::HashMap<(RelFileNode, u64), u32>, } impl SpillWriter { @@ -470,25 +514,28 @@ impl SpillWriter { pub async fn write(&mut self, entry: &SpillEntry) -> Result<()> { let mut body = Vec::with_capacity(128); - let tag: u8 = match entry { - SpillEntry::Heap(_) => 0, - SpillEntry::Chunk(_) => 1, - SpillEntry::ToastDelete(_) => 2, - SpillEntry::Raw(_) => 3, - }; - body.push(tag); - // u32 LE length placeholder, back-patched after body appends in place - let len_off = body.len(); - body.extend_from_slice(&[0u8; 4]); - let inner_start = body.len(); match entry { - SpillEntry::Heap(h) => encode_heap_into(&mut body, h), - SpillEntry::Chunk(c) => encode_chunk_into(&mut body, c), - SpillEntry::ToastDelete(d) => encode_toast_delete_into(&mut body, d), - SpillEntry::Raw(r) => encode_raw_into(&mut body, r), + SpillEntry::Heap(h) => { + let key = (h.descriptor.rfn, h.descriptor_valid_from); + let next_id = self.dict.len() as u32; + let id = *self.dict.entry(key).or_insert(next_id); + if id == next_id { + frame_into(&mut body, 4, |out| { + push_u64(out, h.descriptor_valid_from); + encode_descriptor_bytes(out, &h.descriptor); + }); + } + frame_into(&mut body, 0, |out| { + push_u32(out, id); + encode_heap_into(out, &h.decoded); + }); + } + SpillEntry::Chunk(c) => frame_into(&mut body, 1, |out| encode_chunk_into(out, c)), + SpillEntry::ToastDelete(d) => { + frame_into(&mut body, 2, |out| encode_toast_delete_into(out, d)) + } + SpillEntry::Raw(r) => frame_into(&mut body, 3, |out| encode_raw_into(out, r)), } - let inner_len = (body.len() - inner_start) as u32; - body[len_off..len_off + 4].copy_from_slice(&inner_len.to_le_bytes()); self.file.write_all(&body).await?; self.byte_count += body.len() as u64; Ok(()) @@ -505,6 +552,7 @@ impl SpillWriter { file, path: self.path, header_checked: false, + dict: Vec::new(), }) } @@ -514,6 +562,17 @@ impl SpillWriter { } } +/// `[tag][u32 len LE][body]`, length back-patched after `f` appends in place +fn frame_into(out: &mut Vec, tag: u8, f: impl FnOnce(&mut Vec)) { + out.push(tag); + let len_off = out.len(); + out.extend_from_slice(&[0u8; 4]); + let inner_start = out.len(); + f(out); + let inner_len = (out.len() - inner_start) as u32; + out[len_off..len_off + 4].copy_from_slice(&inner_len.to_le_bytes()); +} + /// Drop `file`, remove `path`, tolerating already-gone (abort races, /// crash-cleanup re-runs) async fn unlink_file(file: File, path: &Path) -> Result<()> { @@ -531,6 +590,9 @@ pub struct SpillReader { /// Lazy header check: first `next()` verifies [`SPILL_MAGIC`] + version, /// so a stale on-disk spill fails cleanly with [`SpillError::Format`] header_checked: bool, + /// Descriptor dictionary rebuilt from tag-4 records in write order; + /// heap bodies reference by index + dict: Vec<(Arc, u64)>, } impl SpillReader { @@ -566,51 +628,74 @@ impl SpillReader { Ok(()) } - /// One entry per call; `Ok(None)` at clean EOF + /// One entry per call; `Ok(None)` at clean EOF. Tag-4 dictionary + /// records fold into `dict` and never surface pub async fn next(&mut self) -> Result> { if !self.header_checked { self.check_header().await?; } - let mut tag_buf = [0u8; 1]; - match self.file.read_exact(&mut tag_buf).await { - Ok(_) => {} - Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), - Err(e) => return Err(e.into()), - } - let mut len_buf = [0u8; 4]; - self.file.read_exact(&mut len_buf).await?; - let len = u32::from_le_bytes(len_buf) as usize; - let mut body = vec![0u8; len]; - self.file.read_exact(&mut body).await?; - let entry = match tag_buf[0] { - 0 => { - let mut cur = Cursor::new(&body); - let h = decode_heap(&mut cur)?; - SpillEntry::Heap(Box::new(h)) - } - 1 => { - let mut cur = Cursor::new(&body); - let c = decode_chunk(&mut cur)?; - SpillEntry::Chunk(c) - } - 2 => { - let mut cur = Cursor::new(&body); - let d = decode_toast_delete(&mut cur)?; - SpillEntry::ToastDelete(d) - } - 3 => { - let mut cur = Cursor::new(&body); - let r = decode_raw(&mut cur)?; - SpillEntry::Raw(Box::new(r)) + loop { + let mut tag_buf = [0u8; 1]; + match self.file.read_exact(&mut tag_buf).await { + Ok(_) => {} + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(e.into()), } - other => { - return Err(SpillError::Format { - offset: 0, - detail: format!("unknown entry tag {other}"), - }); - } - }; - Ok(Some(entry)) + let mut len_buf = [0u8; 4]; + self.file.read_exact(&mut len_buf).await?; + let len = u32::from_le_bytes(len_buf) as usize; + let mut body = vec![0u8; len]; + self.file.read_exact(&mut body).await?; + let mut cur = Cursor::new(&body); + let entry = match tag_buf[0] { + 0 => { + let dict_id = cur.u32()? as usize; + let h = decode_heap(&mut cur)?; + let Some((descriptor, valid_from)) = self.dict.get(dict_id).cloned() else { + return Err(SpillError::Format { + offset: cur.pos, + detail: format!( + "heap references dict id {dict_id}, only {} defined", + self.dict.len(), + ), + }); + }; + SpillEntry::Heap(Box::new(DescribedHeap { + decoded: h, + descriptor, + descriptor_valid_from: valid_from, + })) + } + 1 => SpillEntry::Chunk(decode_chunk(&mut cur)?), + 2 => SpillEntry::ToastDelete(decode_toast_delete(&mut cur)?), + 3 => SpillEntry::Raw(Box::new(decode_raw(&mut cur)?)), + 4 => { + let valid_from = cur.u64()?; + let (d, used) = + decode_descriptor_bytes(&body[cur.pos..]).map_err(|detail| { + SpillError::Format { + offset: cur.pos, + detail, + } + })?; + if cur.pos + used != len { + return Err(SpillError::Format { + offset: cur.pos + used, + detail: "descriptor body length mismatch".into(), + }); + } + self.dict.push((Arc::new(d), valid_from)); + continue; + } + other => { + return Err(SpillError::Format { + offset: 0, + detail: format!("unknown entry tag {other}"), + }); + } + }; + return Ok(Some(entry)); + } } pub async fn unlink(self) -> Result<()> { @@ -646,7 +731,8 @@ fn push_str(out: &mut Vec, s: &str) { push_bytes(out, s.as_bytes()); } -fn encode_heap_into(out: &mut Vec, h: &DecodedHeap) { +/// Shared with the plan spool: one heap's frame-body bytes +pub(crate) fn encode_heap_into(out: &mut Vec, h: &DecodedHeap) { push_u32(out, h.rfn.spc_node); push_u32(out, h.rfn.db_node); push_u32(out, h.rfn.rel_node); @@ -826,6 +912,7 @@ fn encode_toast_delete_into(out: &mut Vec, d: &ToastDelete) { fn encode_raw_into(out: &mut Vec, r: &RawRecord) { out.reserve(32 + r.approx_bytes()); + push_u32(out, r.xid); push_u8(out, r.rm); push_u8(out, r.info); push_u64(out, r.source_lsn); @@ -861,6 +948,10 @@ impl<'a> Cursor<'a> { Self { buf, pos: 0 } } + pub(crate) fn remaining(&self) -> usize { + self.buf.len() - self.pos + } + fn need(&mut self, n: usize) -> Result<&'a [u8]> { if self.pos + n > self.buf.len() { return Err(SpillError::Format { @@ -910,7 +1001,7 @@ impl<'a> Cursor<'a> { } } -fn decode_heap(c: &mut Cursor) -> Result { +pub(crate) fn decode_heap(c: &mut Cursor) -> Result { let spc_node = c.u32()?; let db_node = c.u32()?; let rel_node = c.u32()?; @@ -1087,6 +1178,7 @@ fn decode_toast_delete(c: &mut Cursor) -> Result { } fn decode_raw(c: &mut Cursor) -> Result { + let xid = c.u32()?; let rm = c.u8()?; let info = c.u8()?; let source_lsn = c.u64()?; @@ -1112,6 +1204,7 @@ fn decode_raw(c: &mut Cursor) -> Result { }); } Ok(RawRecord { + xid, rm, info, source_lsn, @@ -1126,32 +1219,69 @@ mod tests { use super::*; use tempfile::tempdir; - fn sample_heap(xid: u32, lsn: u64) -> DecodedHeap { - DecodedHeap { + fn sample_descriptor(rel_node: u32) -> Arc { + Arc::new(RelDescriptor { rfn: RelFileNode { spc_node: 1663, db_node: 5, - rel_node: 16385, + rel_node, }, - xid, - source_lsn: lsn, - op: HeapOp::Insert, - new: Some(DecodedTuple { - columns: vec![ - Some(ColumnValue::Int4(7)), - Some(ColumnValue::Text("hello".into())), - None, - Some(ColumnValue::Null), - Some(ColumnValue::ExternalToast(ToastPointer { - va_rawsize: 1024, - va_extinfo: 0x80000200, - va_valueid: 99, - va_toastrelid: 16400, - })), - ], - partial: false, - }), - old: None, + oid: rel_node, + toast_oid: 16400, + namespace_oid: 2200, + rel_name: crate::schema::RelName::new("public", "spill_t"), + kind: 'r', + persistence: 'p', + replident: crate::schema::ReplIdent::Full { + pk_attnums: Some(vec![1]), + }, + attributes: vec![crate::schema::RelAttr { + attnum: 1, + name: "id".into(), + type_oid: 23, + typmod: -1, + not_null: true, + dropped: false, + type_name: "int4".into(), + type_byval: true, + type_len: 4, + type_align: 'i', + type_storage: 'p', + missing_text: None, + }], + }) + } + + fn sample_heap(xid: u32, lsn: u64) -> DescribedHeap { + DescribedHeap { + decoded: DecodedHeap { + rfn: RelFileNode { + spc_node: 1663, + db_node: 5, + rel_node: 16385, + }, + xid, + source_lsn: lsn, + op: HeapOp::Insert, + new: Some(DecodedTuple { + columns: vec![ + Some(ColumnValue::Int4(7)), + Some(ColumnValue::Text("hello".into())), + None, + Some(ColumnValue::Null), + Some(ColumnValue::ExternalToast(ToastPointer { + va_rawsize: 1024, + va_extinfo: 0x80000200, + va_valueid: 99, + va_toastrelid: 16400, + })), + ], + partial: false, + }), + old: None, + }, + descriptor: sample_descriptor(16385), + descriptor_valid_from: 0x1000, } } @@ -1183,10 +1313,12 @@ mod tests { let mut r = w.finish().await.unwrap(); match r.next().await.unwrap().unwrap() { SpillEntry::Heap(b) => { - assert_eq!(b.xid, 42); - assert_eq!(b.source_lsn, 0x2000); - assert_eq!(b.new.as_ref().unwrap().columns.len(), 5); - let cols = &b.new.as_ref().unwrap().columns; + assert_eq!(b.decoded.xid, 42); + assert_eq!(b.decoded.source_lsn, 0x2000); + assert_eq!(b.descriptor_valid_from, 0x1000); + assert_eq!(b.descriptor, sample_descriptor(16385)); + assert_eq!(b.decoded.new.as_ref().unwrap().columns.len(), 5); + let cols = &b.decoded.new.as_ref().unwrap().columns; assert!(matches!(cols[0], Some(ColumnValue::Int4(7)))); assert!(matches!(cols[1], Some(ColumnValue::Text(ref t)) if t == "hello")); assert!(cols[2].is_none()); @@ -1238,6 +1370,7 @@ mod tests { let store = SpillStore::new(tmp.path().to_path_buf()).unwrap(); let mut w = store.writer(9, 0x300).await.unwrap(); let raw = RawRecord { + xid: 77, rm: 10, info: 0x80, source_lsn: 0x4242, @@ -1267,6 +1400,7 @@ mod tests { SpillEntry::Raw(got) => { assert_eq!(*got, raw); let rec = got.to_xlog_record(); + assert_eq!(rec.header.xact_id, 77, "writer xid restored for _xid"); assert_eq!(rec.header.resource_manager_id, 10); assert_eq!(rec.header.info, 0x80); assert_eq!(rec.blocks.len(), 1); @@ -1288,6 +1422,52 @@ mod tests { r.unlink().await.unwrap(); } + #[test] + fn drop_redundant_images_keeps_image_only_block() { + let block = |data_length: u16, image_length: u16| RawBlock { + block_id: 0, + fork_flags: 0x10 | 0x20, + data_length, + image_length, + hole_offset: 24, + hole_length: 7000, + bimg_info: 0x05, + spc_node: 1663, + db_node: 5, + rel_node: 24681, + block_no: 0, + image: vec![0xAB; image_length as usize], + data: vec![7; data_length as usize], + }; + let mut raw = RawRecord { + xid: 5, + rm: 10, + info: 0, + source_lsn: 0x50, + page_magic: 0xD114, + main_data: vec![8], + // block 0 carries both, block 1 image only (rewrite toast chunk) + blocks: vec![block(4, 100), block(0, 100)], + }; + let before = raw.approx_bytes(); + raw.drop_redundant_images(); + assert!(raw.blocks[0].image.is_empty(), "unread image dropped"); + assert_eq!(raw.blocks[0].image_length, 0); + assert_eq!(raw.blocks[0].fork_flags & 0x10, 0, "HAS_IMAGE cleared"); + assert_eq!(raw.blocks[0].data, vec![7; 4], "decode input kept"); + assert_eq!( + raw.blocks[1].image.len(), + 100, + "image-only block is the FPI-restore path's only input", + ); + assert!(raw.approx_bytes() < before); + // Rebuilt record agrees: block 0 reads as data-carrying, block 1 not + let rec = raw.to_xlog_record(); + assert!(!rec.blocks[0].header.has_image()); + assert!(rec.blocks[0].header.has_data()); + assert!(rec.blocks[1].header.has_image()); + } + #[tokio::test(flavor = "current_thread")] async fn clear_removes_only_spill_files() { let tmp = tempdir().unwrap(); @@ -1412,6 +1592,7 @@ mod tests { file: OpenOptions::new().read(true).open(&path).await.unwrap(), path, header_checked: false, + dict: Vec::new(), }; let err = r.next().await.expect_err("must error on bad tag"); match err { @@ -1422,6 +1603,31 @@ mod tests { } } + /// Pre-xid spill format (v5) fails closed at open; startup wipes the + /// spill dir, so rejection is self-check honesty, never migration + #[tokio::test(flavor = "current_thread")] + async fn old_version_surfaces_format_error() { + let tmp = tempdir().unwrap(); + let path = tmp.path().join("xid-0000000000-0000000000000000.bin"); + let mut bytes = Vec::with_capacity(4); + bytes.extend_from_slice(&SPILL_MAGIC); + bytes.extend_from_slice(&5u16.to_le_bytes()); + tokio::fs::write(&path, &bytes).await.unwrap(); + let mut r = SpillReader { + file: OpenOptions::new().read(true).open(&path).await.unwrap(), + path, + header_checked: false, + dict: Vec::new(), + }; + let err = r.next().await.expect_err("must error on old version"); + match err { + SpillError::Format { detail, .. } => { + assert!(detail.contains("unsupported spill version 5"), "{detail}"); + } + other => panic!("expected Format, got {other:?}"), + } + } + #[tokio::test(flavor = "current_thread")] async fn missing_magic_surfaces_format_error() { let tmp = tempdir().unwrap(); @@ -1433,6 +1639,7 @@ mod tests { file: OpenOptions::new().read(true).open(&path).await.unwrap(), path, header_checked: false, + dict: Vec::new(), }; let err = r.next().await.expect_err("must error on missing magic"); match err { @@ -1537,13 +1744,13 @@ mod tests { HeapOp::Truncate, ] { let mut h = sample_heap(11, 0x600); - h.op = op; + h.decoded.op = op; if matches!(op, HeapOp::Truncate) { // Truncate carries no tuple payload - h.new = None; - h.old = None; + h.decoded.new = None; + h.decoded.old = None; } else { - h.old = Some(DecodedTuple { + h.decoded.old = Some(DecodedTuple { columns: vec![Some(ColumnValue::Int4(42))], partial: false, }); @@ -1555,7 +1762,7 @@ mod tests { let mut ops = Vec::new(); while let Some(e) = r.next().await.unwrap() { if let SpillEntry::Heap(b) = e { - ops.push(b.op); + ops.push(b.decoded.op); } } assert_eq!( diff --git a/src/xact/xact_buffer.rs b/src/xact/xact_buffer.rs index 892a44c7..c3650ca4 100644 --- a/src/xact/xact_buffer.rs +++ b/src/xact/xact_buffer.rs @@ -19,7 +19,7 @@ //! //! Detoast needs the column's type OID to pick `Bytea` vs `Text`. Drain //! calls -//! [`ShadowCatalog::relation_at`](crate::catalog::shadow_catalog::ShadowCatalog::relation_at) +//! [`DescriptorLog::descriptor_at`](crate::catalog::desc_log::DescriptorLog::descriptor_at) //! per heap needing detoast; the catalog's LRU covers repeat lookups so //! a buffer-internal cache would duplicate it. //! @@ -36,8 +36,8 @@ //! Spill-to-ClickHouse (Option B) is deferred; v1 is local-disk-only. use std::cmp::Reverse; -use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque}; -use std::path::PathBuf; +use std::collections::{BinaryHeap, HashMap, VecDeque}; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -46,10 +46,10 @@ use tokio::sync::Mutex; use tracing::Instrument; use walrus::pg::walparser::{RelFileNode, RmId}; -use crate::catalog::shadow_catalog::{CatalogError, ShadowCatalog}; +use crate::catalog::desc_log::{Ambiguity, DescriptorLog, LookupResult}; use crate::decode::decoder_sink::{DecoderSinkError, DecoderStats}; use crate::decode::heap_decoder::{ - ColumnValue, DecodedHeap, HeapOp, ToastPointer, decode_heap_record, + ColumnValue, DecodedHeap, DescribedHeap, HeapOp, ToastPointer, decode_heap_record, }; #[cfg(test)] use crate::decode::wal_xact::{ @@ -60,7 +60,7 @@ use crate::emit::ch_emitter::EmitterStats; use crate::ops::trace::{InflightSnapshotEntry, TxnSpanRegistry, new_txn_span}; use crate::record::{Record, RecordSink, Route, SinkError}; use crate::runtime_config::{ConfigEvent, ConfigTableKind}; -use crate::schema::{RelDescriptor, SchemaEvent, SchemaEventRx}; +use crate::schema::{RelDescriptor, SchemaEvent}; use crate::toast::{ Body, ChunkRefMap, FetchedValue, ToastResolver, ToastRowRef, ToastValueError, ValueRef, check_value_caps, detoasted_value, finish_value, pointer_extsize, @@ -72,56 +72,6 @@ use crate::xact::spill::{ use std::pin::Pin; -/// Epoch-invalidated `RelFileNode` memo shared by drain and decode paths -pub struct RelCache { - epoch: Option>, - seen_epoch: u64, - map: HashMap, -} - -impl RelCache { - pub fn new(epoch: Option>) -> Self { - let seen_epoch = epoch - .as_ref() - .map(|e| e.load(Ordering::Acquire)) - .unwrap_or(0); - Self { - epoch, - seen_epoch, - map: HashMap::new(), - } - } - - pub fn refresh(&mut self) { - if let Some(e) = &self.epoch { - let cur = e.load(Ordering::Acquire); - if cur != self.seen_epoch { - self.seen_epoch = cur; - self.map.clear(); - } - } - } - - pub fn enabled(&self) -> bool { - self.epoch.is_some() - } - - pub fn get(&self, rfn: RelFileNode) -> Option<&V> { - self.map.get(&rfn) - } - - pub fn entry( - &mut self, - rfn: RelFileNode, - ) -> std::collections::hash_map::Entry<'_, RelFileNode, V> { - self.map.entry(rfn) - } - - pub fn insert(&mut self, rfn: RelFileNode, value: V) { - self.map.insert(rfn, value); - } -} - /// Matches PG `logical_decoding_work_mem` default 64 MiB /// (`src/backend/utils/misc/guc_tables.c`) pub const DEFAULT_XACT_BUFFER_MAX: usize = 64 * 1024 * 1024; @@ -192,7 +142,7 @@ impl SubxactTracker { /// `source_lsn` is the WAL LSN stamped at decode; merge-drain orders by it fn entry_lsn(e: &SpillEntry) -> u64 { match e { - SpillEntry::Heap(h) => h.source_lsn, + SpillEntry::Heap(h) => h.decoded.source_lsn, SpillEntry::Chunk(c) => c.source_lsn, SpillEntry::ToastDelete(d) => d.source_lsn, SpillEntry::Raw(r) => r.source_lsn, @@ -240,10 +190,16 @@ impl XactBufferConfig { pub enum XactBufferError { #[error("spill: {0}")] Spill(#[from] SpillError), - #[error("catalog: {0}")] - Catalog(#[from] CatalogError), #[error("observer: {0}")] Observer(String), + /// Descriptor log answered anything but Present for a heap that already + /// decoded once against a covered descriptor — coverage bug, fail closed + #[error("descriptor for {rfn:?} at {lsn:#X} not covered: {got}")] + DescriptorNotCovered { + rfn: RelFileNode, + lsn: u64, + got: String, + }, #[error("toast chunk for value_id={value_id} on rel={toast_relid} missing seq {missing}")] MissingToastChunk { toast_relid: u32, @@ -268,6 +224,85 @@ pub enum XactBufferError { "toast generation for rel {relid} observed without XLOG_SMGR_CREATE marker; fresh snapshot required" )] IncompleteToastGeneration { relid: u32 }, + /// Ordinary stash record refused decode (operation policy). Fatal at + /// drain: dropping it would lose user rows, the exact class raw decode + /// exists to kill. Carries the record's `rm`/`info` so an operator can + /// name the unmodelled WAL op without re-reading the segment + #[error( + "ordinary raw decode for rel {relid} at {lsn:#X} \ + (op {} rm {rm} info {info:#04X}) failed closed: {reason}", + op_label(.rm, .info) + )] + OrdinaryFailClosed { + relid: u32, + lsn: u64, + rm: u8, + info: u8, + reason: FailClosedReason, + }, + /// Stashed record falls inside a recorded ambiguity interval: no + /// descriptor proven safe for its rows, neither decode nor discard is + /// sound. Fail closed; operator takes a fresh snapshot + #[error( + "stash for filenode {rel_node} at {lsn:#X} inside ambiguity \ + [{from_lsn:#X}, {through_lsn:#X})" + )] + StashAmbiguous { + rel_node: u32, + lsn: u64, + from_lsn: u64, + through_lsn: u64, + }, + /// Raw entries drained with no commit-time resolution installed: the + /// discard arm would swallow every stashed row, fence included. Fail + /// closed — resolve_stash must run for any xact that stashed + #[error("drain for xact {top_xid} has stashed records but no resolution")] + MissingStashResolution { top_xid: u32 }, + /// Merged entry carries a writer xid outside the owning xact + + /// subxacts: spill corruption or buffer-key drift. Fail closed before + /// a foreign row can emit under this commit + #[error("drained heap xid {xid} outside owning xact {top}")] + ForeignXid { xid: u32, top: u32 }, +} + +/// `rm`/`info` as the op name the raw decode counters label with +fn op_label(rm: &u8, info: &u8) -> &'static str { + use crate::decode::heap_decoder::{HEAP_OP_LABELS, heap_op_index}; + HEAP_OP_LABELS[heap_op_index(*rm, *info)] +} + +/// Operation-policy verdict for ordinary raw decode +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FailClosedReason { + /// Tuple bytes live only in the block image: FPI without block data, or + /// MULTI_INSERT lacking `XLH_INSERT_CONTAINS_NEW_TUPLE`. wal_level=logical + /// retains registered data through FPIs (`REGBUF_KEEP_DATA`), so logical + /// COPY never lands here regardless of checkpoint timing + ImageOnly, + /// Byte-level decode failure, carries decoder detail + Malformed(String), + /// Operation mutates user rows with no supported decode shape; unknown + /// resource managers land here too + UnsupportedOperation, + /// UPDATE new-tuple prefix/suffix elision references a predecessor image + /// raw decode cannot reconstruct; PG only elides below wal_level=logical + /// (`log_heap_update`, PG src/backend/access/heap/heapam.c) + PartialUpdate, +} + +impl std::fmt::Display for FailClosedReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ImageOnly => f.write_str("image-only operation"), + Self::Malformed(detail) => write!(f, "malformed payload: {detail}"), + Self::UnsupportedOperation => { + f.write_str("unsupported operation with user-row effects") + } + Self::PartialUpdate => { + f.write_str("partial update without reconstructable predecessor") + } + } + } } impl From for XactBufferError { @@ -314,6 +349,10 @@ pub struct XactBufferStats { /// manifest `drain` role, monotonic. The durable-ack sibling lives in /// the pipeline ack collector, not here pub drain_lsn: u64, + /// Cumulative raw-stash payload bytes by first landing; eviction moving + /// a memory-resident entry to spill does not re-count + pub raw_stash_bytes_mem: u64, + pub raw_stash_bytes_spill: u64, } impl XactBufferStats { @@ -378,7 +417,7 @@ struct XactState { /// Filenodes this xact wrote that were invisible at record time /// (same-xact CREATE / TRUNCATE / rewrite generations, or markerless /// tracking). Resolved at commit via `relation_at(rfn, commit_lsn)`. - stash_rfns: HashSet, + stash_rfns: HashMap, /// Per-txn `txn` span; duration = WAL-record→durable latency. span: tracing::Span, /// Child of `span` covering first-buffered→COMMIT-observed (parked-for- @@ -401,23 +440,43 @@ impl XactState { spill: None, spill_bytes: 0, events: Vec::new(), - stash_rfns: HashSet::new(), + stash_rfns: HashMap::new(), span, _wait_span: wait_span, } } } +/// What one stashed filenode's records span, for commit-time resolution +#[derive(Debug, Clone, Copy)] +pub struct StashMark { + /// Lowest record LSN stashed or tracked for this filenode: the lower + /// bound the fence query needs + pub first_lsn: u64, + /// Some record on this filenode was tracked without payload (no + /// `XLOG_SMGR_CREATE` marker), so the set cannot prove completeness + pub payload_free: bool, +} + +impl StashMark { + fn merge(&mut self, other: Self) { + self.first_lsn = self.first_lsn.min(other.first_lsn); + self.payload_free |= other.payload_free; + } +} + /// Approximate byte cost for in-memory accounting; estimate, not exact /// heap allocation. Good enough for the eviction threshold fn approximate_size(entry: &SpillEntry) -> usize { match entry { SpillEntry::Heap(h) => { - let mut sz = std::mem::size_of::(); - if let Some(t) = &h.new { + // size_of:: counts the retained descriptor Arc + + // span key (contents shared, not per-row) + let mut sz = std::mem::size_of::(); + if let Some(t) = &h.decoded.new { sz += tuple_size(t); } - if let Some(t) = &h.old { + if let Some(t) = &h.decoded.old { sz += tuple_size(t); } sz @@ -491,9 +550,17 @@ const MARKER_CAP: usize = 65536; pub enum StashOutcome { /// Resolved toast heap: decode stashed records at drain Toast(Arc), - /// Resolved ordinary heap/index: decode stays fenced off (lower-bound - /// `relation_at` can return a later same-filenode shape), counted - Skip, + /// Resolved ordinary heap: decode stashed records to rows at drain via + /// the merge's pending queue. Carries commit-resolution descriptor + + /// its interval's valid_from + Ordinary { + rel: Arc, + valid_from: u64, + /// Ambiguity intervals overlapping this filenode's stashed span. + /// Resolution happens at the commit's `next_lsn`, past every + /// interval's end, so the fence is applied per record at fold + fence: Vec>, + }, } /// Resolution map for a finishing tree; filenodes absent from `outcomes` @@ -504,19 +571,23 @@ pub struct StashResolution { stats: Option>, } -/// Resolve the finishing tree's stashed filenodes with -/// `relation_at(rfn, commit_lsn)` (replay-gated so shadow reflects the -/// commit), install outcomes for the imminent drain, and queue `O - B` -/// barriers for marker-proven toast generations. A toast heap without its -/// marker fails closed ([`XactBufferError::IncompleteToastGeneration`]). -/// Callers drain the catalog's schema-event channel afterwards: -/// resolution can surface `Added` for newly visible rels. +/// Resolve the finishing tree's stashed filenodes against the descriptor +/// log at the commit's `next_lsn` (capture ran inside the boundary hold, so +/// same-xact CREATE/rewrite descriptors are already covered), install +/// outcomes for the imminent drain, and queue `O - B` barriers for +/// marker-proven toast generations. A toast heap without its marker fails +/// closed ([`XactBufferError::IncompleteToastGeneration`]). +/// +/// Ambiguity travels with the outcome, not the lookup: resolution asks at +/// `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. pub async fn resolve_stash( buffer: &Arc>, - catalog: &Arc>, + log: &DescriptorLog, top_xid: u32, subxids: &[u32], - commit_lsn: u64, + next_lsn: u64, stats: Arc, ) -> std::result::Result<(), XactBufferError> { let rfns = { @@ -531,34 +602,88 @@ pub async fn resolve_stash( } let mut outcomes: HashMap = HashMap::new(); let mut barriers: Vec<(u32, u64)> = Vec::new(); - for rfn in &rfns { - let resolved = { - let mut cat = catalog.lock().await; - cat.relation_at(*rfn, commit_lsn).await - }; - match resolved { - Ok(rel) if rel.kind == 't' => { - let marker = buffer.lock().await.marker_lsn(*rfn); + for (rfn, mark) in &rfns { + let (rfn, mark) = (*rfn, *mark); + match log.descriptor_at_spanned(rfn, next_lsn) { + Ok((rel, _)) if rel.kind == 't' => { + // Chunk layout is fixed, so an interval on a toast heap is an + // unmodelled shape; there is no per-record verdict before + // decode_stashed_toast either way + let fenced = + log.ambiguities_intersecting(rfn, Some(rel.oid), mark.first_lsn, next_lsn); + if let Some(a) = fenced.first() { + return Err(XactBufferError::StashAmbiguous { + rel_node: rfn.rel_node, + lsn: next_lsn, + from_lsn: a.from_lsn, + through_lsn: a.through_lsn, + }); + } + let marker = buffer.lock().await.marker_lsn(rfn); let Some(marker_lsn) = marker else { return Err(XactBufferError::IncompleteToastGeneration { relid: rel.oid }); }; barriers.push((rel.oid, marker_lsn)); - outcomes.insert(*rfn, StashOutcome::Toast(rel)); + outcomes.insert(rfn, StashOutcome::Toast(rel)); + } + Ok((rel, valid_from)) => { + if mark.payload_free { + // Markerless records were tracked without payload before + // the filenode resolved decodable; documented residual, + // `plans/future/catalog_capture_completeness.md` + tracing::warn!( + target: "walshadow::xact_buffer", + relid = rel.oid, + rel_node = rfn.rel_node, + from = format_args!("{:#X}", mark.first_lsn), + "ordinary stash resolved with payload-free records tracked; \ + those rows are not mirrored", + ); + } + let fence = + log.ambiguities_intersecting(rfn, Some(rel.oid), mark.first_lsn, next_lsn); + outcomes.insert( + rfn, + StashOutcome::Ordinary { + rel, + valid_from, + fence, + }, + ); + } + // Point lookup at next_lsn sits past every interval this commit + // published, so this arm answers only for an interval a later + // covered commit opened over the same filenode + Err(LookupResult::Ambiguous(a)) => { + return Err(XactBufferError::StashAmbiguous { + rel_node: rfn.rel_node, + lsn: next_lsn, + from_lsn: a.from_lsn, + through_lsn: a.through_lsn, + }); } - Ok(_) => { - outcomes.insert(*rfn, StashOutcome::Skip); + // Dropped / rotated away by this xid or a later covered commit; + // AEL supersession makes the discard end-state-neutral. + // NotCovered = rel never reached the log: born + gone inside + // this xact's family (capture tombstones only predecessors, a + // commit-time survivor would be Present), so no surviving rows + Err(LookupResult::Dropped | LookupResult::Retired | LookupResult::NotCovered) => {} + // Foreign db never stashes rows worth keeping; counted + // cluster-level, once per filenode + Err(LookupResult::ForeignDb) => { + stats + .stash_foreign_db_skipped + .fetch_add(1, Ordering::Relaxed); } - // Dropped or rotated away by this xid or a later commit already - // replayed; AEL supersession makes the discard end-state-neutral - Err(CatalogError::NotFoundByFilenode(_)) | Err(CatalogError::ForeignDatabase(_)) => {} - Err(e) => return Err(e.into()), + Err(LookupResult::Present(_)) => unreachable!("spanned lookup returns Present as Ok"), } } let mut buf = buffer.lock().await; for (toast_relid, marker_lsn) in barriers { - buf.on_toast_barrier(top_xid, commit_lsn, toast_relid, marker_lsn); + buf.on_toast_barrier(top_xid, next_lsn, toast_relid, marker_lsn); } - buf.forget_markers(&rfns); + let resolved: Vec = rfns.iter().map(|(rfn, _)| *rfn).collect(); + buf.forget_markers(&resolved); buf.install_stash_resolution( top_xid, StashResolution { @@ -612,6 +737,10 @@ pub struct XactBuffer { drain_row_resident: Arc, /// Bytes in transaction body spool files (disk, not resident) toast_spool_bytes: Arc, + /// Raw-decoded heaps queued for pending-first yield + /// (`raw_pending_rows` / `raw_pending_bytes` gauges) + raw_pending_rows: Arc, + raw_pending_bytes: Arc, } impl XactBuffer { @@ -634,6 +763,8 @@ impl XactBuffer { drain_chunk_resident: Arc::new(AtomicU64::new(0)), drain_row_resident: Arc::new(AtomicU64::new(0)), toast_spool_bytes: Arc::new(AtomicU64::new(0)), + raw_pending_rows: Arc::new(AtomicU64::new(0)), + raw_pending_bytes: Arc::new(AtomicU64::new(0)), }) } @@ -661,6 +792,15 @@ impl XactBuffer { self.toast_spool_bytes.load(Ordering::Relaxed) } + /// Raw-decoded heaps awaiting pending-first yield; `0` outside a drain + pub fn raw_pending_rows(&self) -> u64 { + self.raw_pending_rows.load(Ordering::Relaxed) + } + + pub fn raw_pending_bytes(&self) -> u64 { + self.raw_pending_bytes.load(Ordering::Relaxed) + } + /// Monotonic high-water mark of [`Self::drain_resident_bytes`]. For a /// spilled xact this stays near merge-heads + chunk bytes, far below the /// xact's decoded size — the drain-streaming bound. @@ -677,12 +817,20 @@ impl XactBuffer { /// Clear leftover spill files from a prior crash. Cursor file /// guarantees on-disk state was either drained-to-CH or /// replayable from `decoder_lsn`, so the spill dir is always - /// safe to wipe at startup. Caller invokes once before any `on_*`. + /// safe to wipe at startup. Plan-spool leftovers share the dir and the + /// same replayability argument. Caller invokes once before any `on_*`. pub async fn clear_spill_dir(&self) -> std::result::Result<(), XactBufferError> { self.store.clear().await?; + crate::emit::pipeline::plan_spool::clean_plan_files(self.store.dir()) + .map_err(SpillError::from)?; Ok(()) } + /// Transient-state directory shared by spill and plan files + pub fn spill_dir(&self) -> &Path { + self.store.dir() + } + pub fn stats(&self) -> &XactBufferStats { &self.stats } @@ -703,8 +851,8 @@ impl XactBuffer { match e { SpillEntry::Heap(h) => { heap_count += 1; - last_lsn = last_lsn.max(h.source_lsn); - rels.insert((h.rfn.db_node, h.rfn.rel_node)); + last_lsn = last_lsn.max(h.decoded.source_lsn); + rels.insert((h.decoded.rfn.db_node, h.decoded.rfn.rel_node)); } SpillEntry::Chunk(c) => { chunk_count += 1; @@ -750,15 +898,15 @@ impl XactBuffer { out } - /// Detoast descriptor is fetched in [`detoast_heap`] after drain: - /// no per-xact rel cache here, catalog's LRU covers repeat lookups + /// Envelope retains the decode-time descriptor through buffer/spill; + /// detoast and routing read it attached, never a live lookup pub async fn on_heap( &mut self, - decoded: DecodedHeap, + described: DescribedHeap, ) -> std::result::Result<(), XactBufferError> { - let xid = decoded.xid; - let first_lsn = decoded.source_lsn; - let entry = SpillEntry::Heap(Box::new(decoded)); + let xid = described.decoded.xid; + let first_lsn = described.decoded.source_lsn; + let entry = SpillEntry::Heap(Box::new(described)); self.absorb(xid, first_lsn, entry).await } @@ -830,7 +978,7 @@ impl XactBuffer { } } if xid != 0 { - self.state_for(xid, lsn).stash_rfns.insert(rfn); + self.mark_stash(xid, lsn, rfn, false); } } @@ -850,7 +998,7 @@ impl XactBuffer { return Ok(()); }; let lsn = raw.source_lsn; - self.state_for(xid, lsn).stash_rfns.insert(rfn); + self.mark_stash(xid, lsn, rfn, false); self.absorb(xid, lsn, SpillEntry::Raw(Box::new(raw))).await } @@ -858,7 +1006,21 @@ impl XactBuffer { /// set can't prove completeness, so entries aren't kept, but commit /// resolution must still fail closed if the filenode turns out toast pub fn track_unresolvable(&mut self, xid: u32, lsn: u64, rfn: RelFileNode) { - self.state_for(xid, lsn).stash_rfns.insert(rfn); + self.mark_stash(xid, lsn, rfn, true); + } + + /// Note a filenode in `xid`'s resolution set. Records arrive in WAL + /// order per xid, so the first mark carries the lowest LSN + fn mark_stash(&mut self, xid: u32, lsn: u64, rfn: RelFileNode, payload_free: bool) { + let mark = StashMark { + first_lsn: lsn, + payload_free, + }; + self.state_for(xid, lsn) + .stash_rfns + .entry(rfn) + .and_modify(|m| m.merge(mark)) + .or_insert(mark); } /// Fast path for the decoder: a filenode already stashed under `xid` @@ -868,19 +1030,25 @@ impl XactBuffer { pub fn is_stash_candidate(&self, xid: u32, rfn: RelFileNode) -> bool { self.inflight .get(&xid) - .is_some_and(|st| st.stash_rfns.contains(&rfn)) + .is_some_and(|st| st.stash_rfns.contains_key(&rfn)) } - /// Union of stash candidates across the finishing tree - pub fn stash_candidates(&self, xids: &[u32]) -> Vec { - let mut out: Vec = Vec::new(); + /// Union of stash candidates across the finishing tree, rfn-ordered for + /// deterministic resolution; marks merge (min LSN, sticky payload_free) + pub fn stash_candidates(&self, xids: &[u32]) -> Vec<(RelFileNode, StashMark)> { + let mut merged: HashMap = HashMap::new(); for x in xids { if let Some(st) = self.inflight.get(x) { - out.extend(st.stash_rfns.iter().copied()); + for (rfn, mark) in &st.stash_rfns { + merged + .entry(*rfn) + .and_modify(|m| m.merge(*mark)) + .or_insert(*mark); + } } } - out.sort_unstable(); - out.dedup(); + let mut out: Vec<(RelFileNode, StashMark)> = merged.into_iter().collect(); + out.sort_unstable_by_key(|(rfn, _)| *rfn); out } @@ -934,6 +1102,7 @@ impl XactBuffer { entry: SpillEntry, ) -> std::result::Result<(), XactBufferError> { let sz = approximate_size(&entry); + let raw_sz = matches!(&entry, SpillEntry::Raw(_)).then_some(sz as u64); let st = self.state_for(xid, first_lsn); if let Some(spill) = st.spill.as_mut() { // Already spilling: append straight to disk @@ -941,10 +1110,12 @@ impl XactBuffer { let bc = spill.byte_count(); let prev = std::mem::replace(&mut st.spill_bytes, bc); self.stats.spill_bytes_active += bc - prev; + self.stats.raw_stash_bytes_spill += raw_sz.unwrap_or(0); } else { st.in_mem.push(entry); st.in_mem_bytes += sz; self.bytes_in_memory += sz; + self.stats.raw_stash_bytes_mem += raw_sz.unwrap_or(0); } self.stats.bytes_in_memory = self.bytes_in_memory as u64; self.maybe_evict().await?; @@ -1007,6 +1178,7 @@ impl XactBuffer { // created once memory-held chunk bytes cross `toast_body_mem_max` top_xid: u32, commit_lsn: u64, + allowed_xids: std::collections::HashSet, ) -> std::result::Result { let mut gauge = self.drain_gauge(&self.drain_head_resident); let mut sources = Vec::with_capacity(states.len()); @@ -1024,13 +1196,19 @@ impl XactBuffer { }; let in_mem = std::mem::take(&mut st.in_mem); sources.push(MergeSource::open(reader, in_mem, &mut gauge).await?); - // Arrival order == WAL order: decoder sink pushes on observe, - // so the Vec is already source_lsn ASC - events.push(std::mem::take(&mut st.events).into()); + // Two producers push events: the worker at observe order and + // the pump at capture time keyed bias-early valid_from — LSN + // order is not arrival order. Stable sort keeps same-LSN + // arrival order (Added before dependent Changed) + let mut evs = std::mem::take(&mut st.events); + evs.sort_by_key(|(lsn, _)| *lsn); + events.push(evs.into()); } Ok(MergedDrain { sources, events, + allowed_xids, + pending_heaps: VecDeque::new(), chunks: ChunkRefMap::new(), chunk_bytes: 0, collect_rows, @@ -1040,6 +1218,12 @@ impl XactBuffer { gauge, chunk_gauge: self.drain_gauge(&self.drain_chunk_resident), row_gauge: self.drain_gauge(&self.drain_row_resident), + pending_gauge: PendingGauge { + rows: self.raw_pending_rows.clone(), + bytes: self.raw_pending_bytes.clone(), + held_rows: 0, + held_bytes: 0, + }, spool: None, spool_dir: self.store.dir().to_path_buf(), spool_xid: top_xid, @@ -1106,9 +1290,25 @@ impl XactBuffer { self.bytes_in_memory = self.bytes_in_memory.saturating_sub(st.in_mem_bytes); } self.stats.bytes_in_memory = self.bytes_in_memory as u64; - let stash = self.pending_stash.remove(&top_xid).unwrap_or_default(); + // Absent resolution would send every Raw entry through fold_raw's + // discard arm — fence included. Resolution is installed by + // resolve_stash for any tree that stashed, so absence is a wiring + // bug, not a verdict + let stash = match self.pending_stash.remove(&top_xid) { + Some(res) => res, + None if states.iter().all(|st| st.stash_rfns.is_empty()) => StashResolution::default(), + None => return Err(XactBufferError::MissingStashResolution { top_xid }), + }; + let allowed_xids = xids.iter().copied().collect(); let merged = self - .open_drain(states, collect_rows, stash, top_xid, commit_lsn) + .open_drain( + states, + collect_rows, + stash, + top_xid, + commit_lsn, + allowed_xids, + ) .await?; self.stats.committed_xacts_total += 1; Ok(CommittedDrain { @@ -1175,9 +1375,13 @@ impl XactBuffer { st.span.record("outcome", "aborted"); // Aborted creates leave their filenodes forever unresolvable; // drop the markers with the states - for rfn in &st.stash_rfns { + for rfn in st.stash_rfns.keys() { self.markers.remove(rfn); } + // A resolution installed for a xid that then aborted must not + // outlive it: the next xact reusing the xid would fold its raws + // under a foreign descriptor and a foreign fence + self.pending_stash.remove(&x); any = true; self.stats.xacts_active = self.stats.xacts_active.saturating_sub(1); self.bytes_in_memory = self.bytes_in_memory.saturating_sub(st.in_mem_bytes); @@ -1254,6 +1458,39 @@ impl Drop for ResidentGauge { } } +/// Pending-queue share of the raw fanout gauges; drop releases whatever +/// an abandoned drain still held +struct PendingGauge { + rows: Arc, + bytes: Arc, + held_rows: u64, + held_bytes: u64, +} + +impl PendingGauge { + fn add(&mut self, bytes: usize) { + self.held_rows += 1; + self.held_bytes += bytes as u64; + self.rows.fetch_add(1, Ordering::Relaxed); + self.bytes.fetch_add(bytes as u64, Ordering::Relaxed); + } + + fn sub(&mut self, bytes: usize) { + let bytes = (bytes as u64).min(self.held_bytes); + self.held_rows = self.held_rows.saturating_sub(1); + self.held_bytes -= bytes; + self.rows.fetch_sub(1, Ordering::Relaxed); + self.bytes.fetch_sub(bytes, Ordering::Relaxed); + } +} + +impl Drop for PendingGauge { + fn drop(&mut self) { + self.rows.fetch_sub(self.held_rows, Ordering::Relaxed); + self.bytes.fetch_sub(self.held_bytes, Ordering::Relaxed); + } +} + /// Lazy merge source for one xid: spill-reader head (older in WAL order) /// chained with the in-mem tail, one decoded entry resident. `pop` refills /// from the reader until EOF, then drains `in_mem`. The EOF reader parks in @@ -1320,7 +1557,7 @@ impl MergeSource { } enum MergeItem { - Heap(Box), + Heap(Box), Event(DrainEntry), } @@ -1349,6 +1586,12 @@ enum MergeItem { struct MergedDrain { sources: Vec, events: Vec>, + /// Heaps decoded from one raw record (MULTI_INSERT fans one record out + /// to N tuples), yielded in tuple order before the merge advances past + /// the record's LSN. Event-first tie break is preserved: every event at + /// `lsn <= L` drained before the raw at `L` was popped. Queued bytes + /// charge `gauge`, released as each heap yields + pending_heaps: VecDeque>, /// Live (unsealed) generation chunks: ChunkRefMap, chunk_bytes: usize, @@ -1358,6 +1601,10 @@ struct MergedDrain { row_bytes: usize, /// Commit-time verdicts for stashed filenodes; empty when nothing stashed stash: StashResolution, + /// Owning xact + subxacts: every yielded heap's writer xid must be a + /// member (spec validation "decoded xid matches owning xact/subxact"); + /// a stranger means spill corruption or buffer-key drift, fail closed + allowed_xids: std::collections::HashSet, /// Merge heads + in-mem tail gauge: ResidentGauge, /// Unsealed chunk map (memory bodies + ref metadata, never file @@ -1365,6 +1612,9 @@ struct MergedDrain { chunk_gauge: ResidentGauge, /// Collected mirror rows; shares split off with each taken batch row_gauge: ResidentGauge, + /// Pending-queue view for the `raw_pending_*` gauges; queued bytes + /// still charge `gauge` for resident accounting + pending_gauge: PendingGauge, /// Lazily created at threshold crossing; None while memory-resident spool: Option, spool_dir: PathBuf, @@ -1384,6 +1634,15 @@ struct MergedDrain { impl MergedDrain { async fn next(&mut self) -> std::result::Result, XactBufferError> { loop { + // Pending head first: a raw record's fanned-out tuples all yield + // before any source or event past its LSN is considered + if let Some(h) = self.pending_heaps.pop_front() { + let sz = h.approx_bytes(); + self.gauge.sub(sz); + self.pending_gauge.sub(sz); + self.check_owned(h.decoded.xid)?; + return Ok(Some(MergeItem::Heap(h))); + } enum Pick { Data(usize), Event(usize), @@ -1417,7 +1676,10 @@ impl MergedDrain { .await? .expect("just peeked head"); match entry { - SpillEntry::Heap(h) => return Ok(Some(MergeItem::Heap(h))), + SpillEntry::Heap(h) => { + self.check_owned(h.decoded.xid)?; + return Ok(Some(MergeItem::Heap(h))); + } SpillEntry::Chunk(c) => self.fold_chunk(c)?, SpillEntry::ToastDelete(d) => self.fold_delete(d)?, SpillEntry::Raw(raw) => self.fold_raw(&raw)?, @@ -1427,6 +1689,16 @@ impl MergedDrain { } } + fn check_owned(&self, xid: u32) -> std::result::Result<(), XactBufferError> { + if self.allowed_xids.contains(&xid) { + return Ok(()); + } + Err(XactBufferError::ForeignXid { + xid, + top: self.spool_xid, + }) + } + /// Cap resident ref metadata before allocating more; typed /// non-retryable error fails the drain loud, replay-safe fn reserve_meta(&mut self, n: usize) -> std::result::Result<(), XactBufferError> { @@ -1504,8 +1776,9 @@ impl MergedDrain { /// Decode a stashed record against its commit-time verdict: toast heaps /// fold chunks/tombstones into the same maps as live-path entries (so - /// same-xact referrers detoast and mirror rows flow), fenced heaps and - /// unresolvable filenodes count and drop + /// same-xact referrers detoast and mirror rows flow), ordinary heaps + /// fan out rows through the pending queue, unresolvable filenodes + /// (end-state-neutral verdicts only) count and drop fn fold_raw(&mut self, raw: &RawRecord) -> std::result::Result<(), XactBufferError> { let Some(rfn) = raw.rfn() else { return Ok(()); @@ -1517,17 +1790,32 @@ impl MergedDrain { }; let rel = match self.stash.outcomes.get(&rfn) { Some(StashOutcome::Toast(rel)) => rel.clone(), - Some(StashOutcome::Skip) => { - bump(&|s| &s.toast_stash_skipped); - return Ok(()); + Some(StashOutcome::Ordinary { + rel, + valid_from, + fence, + }) => { + let (rel, valid_from, fence) = (rel.clone(), *valid_from, fence.clone()); + return self.fold_raw_ordinary(raw, rel, valid_from, &fence); } + // No outcome = the filenode resolved Dropped / Retired / + // NotCovered: rotated or dropped by this commit or a later + // covered one, so under AccessExclusiveLock no row on it + // outlives the commit. That argument, not the fence, is what + // makes this discard sound None => { bump(&|s| &s.toast_stash_discarded); return Ok(()); } }; bump(&|s| &s.toast_stash_decoded); - for op in decode_stashed_toast(raw, &rel)? { + let ops = decode_stashed_toast(raw, &rel)?; + if let Some(s) = &self.stash.stats { + s.raw_decode_toast_ops.bump(raw.rm, raw.info); + s.raw_decode_rows_ops + .add(raw.rm, raw.info, ops.len() as u64); + } + for op in ops { match op { StashedToastOp::Chunk(c) => self.fold_chunk(c)?, StashedToastOp::Delete(d) => self.fold_delete(d)?, @@ -1536,6 +1824,115 @@ impl MergedDrain { Ok(()) } + /// Decode one ordinary raw record to zero or more heaps in tuple order, + /// queued for pending-first yield so a MULTI_INSERT's whole fanout + /// precedes anything past the record's LSN. Rebuilt header carries the + /// writer xid, so decoded heaps keep the source `_xid`. + /// + /// Operation policy: admit only shapes whose logical content is provably + /// whole in the record, skip page maintenance, fail closed on anything + /// else — silent skip here is the row-loss class raw decode exists to + /// kill. `fence` is the filenode's overlapping ambiguity intervals; a + /// record inside one has no proven reader and fails closed before decode + fn fold_raw_ordinary( + &mut self, + raw: &RawRecord, + rel: Arc, + valid_from: u64, + fence: &[Arc], + ) -> std::result::Result<(), XactBufferError> { + use crate::decode::heap_decoder::{ + XLH_INSERT_CONTAINS_NEW_TUPLE, XLOG_HEAP_CONFIRM, XLOG_HEAP_DELETE, + XLOG_HEAP_HOT_UPDATE, XLOG_HEAP_INSERT, XLOG_HEAP_LOCK, XLOG_HEAP_OPMASK, + XLOG_HEAP_UPDATE, XLOG_HEAP2_MULTI_INSERT, + }; + let fail = |reason: FailClosedReason| XactBufferError::OrdinaryFailClosed { + relid: rel.oid, + lsn: raw.source_lsn, + rm: raw.rm, + info: raw.info, + reason, + }; + if let Some(a) = fence + .iter() + .find(|a| a.from_lsn <= raw.source_lsn && raw.source_lsn < a.through_lsn) + { + return Err(XactBufferError::StashAmbiguous { + rel_node: rel.rfn.rel_node, + lsn: raw.source_lsn, + from_lsn: a.from_lsn, + through_lsn: a.through_lsn, + }); + } + let rec = raw.to_xlog_record(); + // FPI consumed the registered tuple data; wal_level=logical retains + // it (REGBUF_KEEP_DATA), so this means a non-logical writer + let image_only = rec + .blocks + .first() + .is_some_and(|b| b.header.has_image() && !b.header.has_data()); + let op = raw.info & XLOG_HEAP_OPMASK; + if raw.rm == RmId::Heap as u8 { + match op { + XLOG_HEAP_INSERT | XLOG_HEAP_UPDATE | XLOG_HEAP_HOT_UPDATE if image_only => { + return Err(fail(FailClosedReason::ImageOnly)); + } + // DELETE carries its whole payload in main_data + XLOG_HEAP_INSERT | XLOG_HEAP_UPDATE | XLOG_HEAP_HOT_UPDATE | XLOG_HEAP_DELETE => {} + // LOCK changes no row; CONFIRM finalizes a speculative + // insert whose tuple decoded from its own record + XLOG_HEAP_LOCK | XLOG_HEAP_CONFIRM => return Ok(()), + // TRUNCATE intercepts at the sink, INPLACE never targets + // ordinary heaps: reaching either breaks the verdict + _ => return Err(fail(FailClosedReason::UnsupportedOperation)), + } + } else if raw.rm == RmId::Heap2 as u8 { + if op != XLOG_HEAP2_MULTI_INSERT { + // PRUNE/VACUUM/FREEZE/VISIBLE/LOCK_UPDATED/NEW_CID/REWRITE: + // page maintenance, no logical row change + return Ok(()); + } + // Absent flag means a sub-logical wal_level wrote the record and + // an FPI may have consumed its tuple data + if image_only + || rec + .main_data + .first() + .is_some_and(|f| f & XLH_INSERT_CONTAINS_NEW_TUPLE == 0) + { + return Err(fail(FailClosedReason::ImageOnly)); + } + } else { + // Sink stashes heap/heap2 only; anything else broke that invariant + return Err(fail(FailClosedReason::UnsupportedOperation)); + } + let decoded_set = decode_heap_record(&rec, raw.source_lsn, &rel) + .map_err(|e| fail(FailClosedReason::Malformed(e.to_string())))?; + if let Some(s) = &self.stash.stats { + s.raw_decode_ordinary_ops.bump(raw.rm, raw.info); + s.raw_decode_rows_ops + .add(raw.rm, raw.info, decoded_set.len() as u64); + } + for decoded in decoded_set { + // New-tuple prefix/suffix elision references a same-page + // predecessor image this path cannot reconstruct. Replident-shaped + // old keys keep live-path semantics + if decoded.new.as_ref().is_some_and(|t| t.partial) { + return Err(fail(FailClosedReason::PartialUpdate)); + } + let heap = DescribedHeap { + decoded, + descriptor: rel.clone(), + descriptor_valid_from: valid_from, + }; + let sz = heap.approx_bytes(); + self.gauge.add(sz); + self.pending_gauge.add(sz); + self.pending_heaps.push_back(Box::new(heap)); + } + Ok(()) + } + /// Make appended bodies readable through the spool handle; no-op /// while memory-resident or when nothing buffered fn flush_spool(&mut self) -> std::result::Result<(), XactBufferError> { @@ -1680,7 +2077,7 @@ pub struct OrderedEvent { /// slice a barrier the reorder coordinator serializes against ClickHouse. pub struct DrainedBatch { /// `source_lsn` ASC within the slice; later slices strictly follow. - pub heaps: Vec, + pub heaps: Vec, pub ordered_events: Vec, /// Chunk generations sealed so far, oldest first. A chunk's WAL position /// precedes its referrer, so every heap's value lives in exactly one @@ -1706,8 +2103,8 @@ pub enum WalkStep { upto: usize, }, Event(DrainEntry), - Truncate(DecodedHeap), - Heap(DecodedHeap), + Truncate(DescribedHeap), + Heap(DescribedHeap), } /// [`DrainedBatch`] decomposed into its apply plan plus the payload fields @@ -1743,7 +2140,7 @@ impl DrainedBatch { steps.push(WalkStep::Rows { upto: e.row_idx }); steps.push(WalkStep::Event(e.event)); } - if matches!(heap.op, HeapOp::Truncate) { + if matches!(heap.decoded.op, HeapOp::Truncate) { let upto = trunc .next() .expect("truncate_rows cursor per Truncate heap"); @@ -1795,7 +2192,7 @@ impl CommittedDrain { let Some(m) = self.merged.as_mut() else { return Ok(None); }; - let mut heaps: Vec = Vec::new(); + let mut heaps: Vec = Vec::new(); let mut ordered_events: Vec = Vec::new(); let mut truncate_rows: Vec = Vec::new(); let mut bytes = 0usize; @@ -1808,7 +2205,7 @@ impl CommittedDrain { event, }), Some(MergeItem::Heap(h)) => { - if h.op == HeapOp::Truncate { + if h.decoded.op == HeapOp::Truncate { truncate_rows.push(m.rows.len()); } bytes += h.approx_bytes(); @@ -1856,24 +2253,18 @@ impl CommittedDrain { /// this call. `None` without budget or external values. /// Pub for the decode pool, gap replay, and tests. pub async fn detoast_heap( - heap: &mut DecodedHeap, + heap: &mut DescribedHeap, // Xact body spool backing `File` refs; None while memory-resident spool: Option<&BodySpoolFile>, // Ref-map generations, oldest first; a value lives in exactly one // (live map on the serial path, sealed drain-batch generations on the // parallel path) chunk_maps: &[&ChunkRefMap], - catalog: &Arc>, - // Async decode pool can lag past a DDL and re-resolve an older heap, - // so it reuses the inline cached descriptor without mutating cache or - // emitting events (`ShadowCatalog::relation_at_pooled`). WAL-ordered - // observer drain passes `false` for the normal cache-populating path. - pooled: bool, resolver: &ToastResolver, ) -> std::result::Result, XactBufferError> { let mut pointers: Vec = Vec::new(); - collect_toast_pointers(heap.new.as_ref(), &mut pointers); - collect_toast_pointers(heap.old.as_ref(), &mut pointers); + collect_toast_pointers(heap.decoded.new.as_ref(), &mut pointers); + collect_toast_pointers(heap.decoded.old.as_ref(), &mut pointers); if pointers.is_empty() { return Ok(None); } @@ -1885,14 +2276,9 @@ pub async fn detoast_heap( Some(b) => Some(b.acquire(leaf_need).await), None => None, }; - let rel: Arc = { - let mut cat = catalog.lock().await; - if pooled { - cat.relation_at_pooled(heap.rfn, heap.source_lsn).await? - } else { - cat.relation_at(heap.rfn, heap.source_lsn).await? - } - }; + // Attached at decode: same descriptor interpretation from decode to + // detoast regardless of captures landing in between + let rel = heap.descriptor.clone(); let mut uses: HashMap<(u32, u32), u32> = HashMap::new(); for p in &pointers { *uses.entry((p.va_toastrelid, p.va_valueid)).or_default() += 1; @@ -1901,15 +2287,15 @@ pub async fn detoast_heap( spool, xact_maps: chunk_maps, resolver, - source_lsn: heap.source_lsn, + source_lsn: heap.decoded.source_lsn, uses, cache: HashMap::new(), retained: 0, }; - if let Some(t) = heap.new.as_mut() { + if let Some(t) = heap.decoded.new.as_mut() { res.resolve_tuple(t, &rel).await?; } - if let Some(t) = heap.old.as_mut() { + if let Some(t) = heap.decoded.old.as_mut() { res.resolve_tuple(t, &rel).await?; } if let Some(p) = leaf.as_mut() { @@ -2143,73 +2529,30 @@ pub(crate) fn reassemble_value_ref( /// [`ToastChunk`]; semantic errors absorb into [`DecoderStats`] rather /// than poison the stream. pub struct BufferingDecoderSink { - catalog: Arc>, + log: Arc, buffer: Arc>, stats: Arc, - /// `None` keeps the sink schema-unaware (greenfield bootstrap, tests) - schema_events: Option, /// `txn` span registry. When set (tracing on), the decoder parents its - /// per-record `catalog.gate`/`decode` spans under the xact's `txn` span - /// (via `decode_parent`, set only for the first record), so the - /// shadow-replay gate shows up nested inside the transaction's span. - /// `None` ⇒ those spans are skipped (no parent to attach to). + /// per-record `decode` spans under the xact's `txn` span (via + /// `decode_parent`, set only for the first record). `None` ⇒ those + /// spans are skipped (no parent to attach to). span_registry: Option, - /// Per-`rfn` descriptor memo (see [`RelCache`]). Lazily created on the - /// first lookup, since `new` can't take the async catalog lock. - rel_cache: Option>>, - /// Bump target for [`Record::catalog_signal`], the sole record-ordered - /// bump site (mapping writes + SIGHUP reload bump out-of-band). This - /// sink runs on the queueing worker, which can lag the pump by - /// thousands of records; an observe-time bump would be consumable by a - /// pre-DDL record's lookup, which fetches from a shadow that hasn't - /// replayed the DDL's commit and caches the pre-DDL descriptor as - /// fresh with no later bump to flush it. Bumping when the DDL record - /// itself passes through keeps consumption in-order: any later lookup - /// of the altered relation is triggered by a record past the DDL's - /// commit (AccessExclusive excludes interleaved rows), so its replay - /// gate guarantees a fresh fetch. `None` (bootstrap, tests without an - /// epoch) skips the bump - invalidation_epoch: Option>, - /// [`CatalogSignal::InvalidateSweep`](crate::record::CatalogSignal::InvalidateSweep) - /// sibling of `invalidation_epoch`: arms `ShadowCatalog::sweep_dropped` - /// at worker position, keyed by the record's xid so the commit sink - /// consumes it only at that xact's own commit - pending_sweeps: Option, /// Source-PG schema holding the `config_*` overlay tables. `Some` diverts /// their heap writes to `on_config_event` (never CH); `None` = overlay off. config_schema: Option>, } impl BufferingDecoderSink { - pub fn new(catalog: Arc>, buffer: Arc>) -> Self { + pub fn new(log: Arc, buffer: Arc>) -> Self { Self { - catalog, + log, buffer, stats: Arc::new(DecoderStats::default()), - schema_events: None, span_registry: None, - rel_cache: None, - invalidation_epoch: None, - pending_sweeps: None, config_schema: None, } } - /// Wire [`Record::catalog_signal`] targets: the invalidation-epoch - /// bump (same `Arc` as `ShadowCatalog::set_invalidation_epoch`) - /// and the DROP-sweep armer (same handle as the commit sink's - /// `with_pending_sweeps`). Production must set these whenever the - /// sink runs behind a `QueueingRecordSink`. - pub fn with_catalog_signals( - mut self, - invalidation: Arc, - pending_sweeps: Option, - ) -> Self { - self.invalidation_epoch = Some(invalidation); - self.pending_sweeps = pending_sweeps; - self - } - /// Names the source-PG schema whose `config_*` tables carry the runtime /// config overlay (`[runtime_config] schema`). Their heap writes divert to /// `on_config_event` instead of CH routing (plan §2); `None` keeps the @@ -2227,14 +2570,6 @@ impl BufferingDecoderSink { self } - /// Share the same `rx` with the reorder coordinator: channel drains - /// from both sides (decoder for Added/Changed at fetch time, reorder - /// for Dropped at commit via `sweep_dropped`). - pub fn with_schema_events(mut self, rx: SchemaEventRx) -> Self { - self.schema_events = Some(rx); - self - } - pub fn stats(&self) -> &DecoderStats { &self.stats } @@ -2243,35 +2578,6 @@ impl BufferingDecoderSink { self.stats.clone() } - /// Route catalog-fetch [`SchemaEvent`]s into the buffer stamped - /// `(xid, source_lsn)` so the commit drain sorts them with the heap - /// writes that triggered the refetch. - async fn drain_schema_events( - &mut self, - xid: u32, - source_lsn: u64, - ) -> std::result::Result<(), SinkError> { - let Some(rx) = self.schema_events.as_ref() else { - return Ok(()); - }; - // Heap2 VACUUM / FREEZE carry xact_id=0 (non-transactional) but - // still drive `relation_at`. Buffering under xid=0 creates an - // inflight entry that never commits, pinning `emitter_ack_lsn` - // behind a phantom xact; leave events for the next real-xid drain. - if xid == 0 { - return Ok(()); - } - let pending = drain_pending_schema_events(rx); - if pending.is_empty() { - return Ok(()); - } - let mut buf = self.buffer.lock().await; - for ev in pending { - buf.on_schema_event(xid, source_lsn, ev); - } - Ok(()) - } - /// Stash raw inputs for a record whose filenode is invisible at record /// time. Marker-proven filenodes keep payload for commit-time decode /// ([`resolve_stash`]); markerless ones are tracked payload-free so a @@ -2289,10 +2595,12 @@ impl BufferingDecoderSink { record.source_lsn, record.page_magic, ); + let (rm, info) = (raw.rm, raw.info); buf.stash_raw(xid, raw).await.map_err(SinkError::from)?; self.stats .toast_stash_buffered .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.stats.raw_stash_marker_ops.bump(rm, info); } else { buf.track_unresolvable(xid, record.source_lsn, rfn); self.stats @@ -2304,7 +2612,7 @@ impl BufferingDecoderSink { /// Push one `HeapOp::Truncate` per relation. TRUNCATE uniquely /// carries pg_class OIDs (not relfilenodes) and no block ref, so the - /// standard `relation_at(rfn)` path doesn't fit. + /// standard by-rfn lookup doesn't fit. async fn handle_truncate(&mut self, record: &Record<'_>) -> std::result::Result<(), SinkError> { let Some(parsed) = crate::filter::main_data::parse_xl_heap_truncate(&record.parsed.main_data) @@ -2316,30 +2624,21 @@ impl BufferingDecoderSink { }; let xid = record.parsed.header.xact_id; let source_lsn = record.source_lsn; - // Gate on shadow replaying past source_lsn so each relid's - // pg_class entry is fresh - if source_lsn > 0 { - let mut cat = self.catalog.lock().await; - cat.wait_for_replay(source_lsn) - .await - .map_err(|e| SinkError::from(DecoderSinkError::from(e)))?; - } for relid in parsed.relids { - let rel = { - let mut cat = self.catalog.lock().await; - match cat.relation_by_oid(relid).await { - Ok(r) => r, - Err(CatalogError::NotFoundByOid(_)) => { - self.stats - .catalog_not_found - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - continue; - } - Err(e) => return Err(DecoderSinkError::from(e).into()), - } + // Same-xact CREATE + TRUNCATE: the rel's Added has no batch yet + // (capture runs at commit) → NotCovered, nothing lives to wipe. + // Ambiguous folds into the same skip: TRUNCATE reads no tuple, so + // the fence has nothing to protect, and an interval covering this + // LSN is unreachable anyway — TRUNCATE rotates the filenode (so + // its own commit publishes no in-place verdict) and a concurrent + // xact cannot hold this rel's AccessExclusiveLock + let Ok((rel, valid_from)) = self.log.descriptor_by_oid_at_spanned(relid, source_lsn) + else { + self.stats + .catalog_not_found + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + continue; }; - // relation_by_oid may emit Added/Changed if the rel rotated - self.drain_schema_events(xid, source_lsn).await?; // CH has no per-table internal toast; only user heap // ('r'/'p') TRUNCATE propagates if rel.kind != 'r' && rel.kind != 'p' { @@ -2355,7 +2654,13 @@ impl BufferingDecoderSink { }; self.stats.record(&decoded); let mut buf = self.buffer.lock().await; - buf.on_heap(decoded).await.map_err(SinkError::from)?; + buf.on_heap(DescribedHeap { + decoded, + descriptor: rel, + descriptor_valid_from: valid_from, + }) + .await + .map_err(SinkError::from)?; } Ok(()) } @@ -2368,19 +2673,6 @@ impl RecordSink for BufferingDecoderSink { ) -> Pin> + Send + 'a>> { Box::pin(async move { - // Bump before anything else so subsequent records' lookups - // (this record routes ToShadow, no lookups of its own) see the - // signal in stream order (see `invalidation_epoch` field doc) - if record.catalog_signal != crate::record::CatalogSignal::None { - if let Some(e) = &self.invalidation_epoch { - e.fetch_add(1, std::sync::atomic::Ordering::Release); - } - if record.catalog_signal == crate::record::CatalogSignal::InvalidateSweep - && let Some(p) = &self.pending_sweeps - { - p.arm(record.parsed.header.xact_id); - } - } let rm = record.parsed.header.resource_manager_id; // TRUNCATE rides Route::ToShadow (shadow replays it) but the // decoder still fans out per-relid HeapOp::Truncate for CH. @@ -2420,12 +2712,30 @@ impl RecordSink for BufferingDecoderSink { .fetch_add(1, std::sync::atomic::Ordering::Relaxed); return Ok(()); }; - // `decode_parent` set only for the first record (holds the replay - // gate): `catalog.gate` wraps `relation_at`, `decode` the parse. let txn_xid = record.parsed.header.xact_id; + // Catalog-dirty tree: hold raw for commit-time resolution. + // Unlike marker-gated stash admission below, payload is always + // retained — a Present predecessor descriptor doesn't prove + // decodability inside the dirty interval + if record.defer_catalog_decode && txn_xid != 0 { + let mut raw = crate::xact::spill::RawRecord::from_parsed( + &record.parsed, + record.source_lsn, + record.page_magic, + ); + raw.drop_redundant_images(); + let (rm, info) = (raw.rm, raw.info); + let mut buf = self.buffer.lock().await; + buf.stash_raw(txn_xid, raw).await.map_err(SinkError::from)?; + self.stats + .raw_stash_deferred + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.stats.raw_stash_dirty_ops.bump(rm, info); + return Ok(()); + } // Known-invisible filenode for this xact (already stashed or // marker-registered): its pg_class row stays MVCC-invisible - // until commit, so skip the replay-gated lookup per record + // until commit, so the log has no entry yet either if txn_xid != 0 && self.buffer.lock().await.is_stash_candidate(txn_xid, rfn) { return self.stash_invisible(record, rfn).await; } @@ -2437,72 +2747,68 @@ impl RecordSink for BufferingDecoderSink { .span_registry .as_ref() .and_then(|r| r.decode_parent(txn_xid)); - // A hit skips the catalog lock + `relation_at` replay gate; safe - // because an unchanged epoch means no DDL invalidated the descriptor. - let cached = self.rel_cache.as_mut().and_then(|c| { - c.refresh(); - c.get(rfn).cloned() - }); - let rel = if let Some(desc) = cached { - desc - } else { - let gate_span = decode_parent - .as_ref() - .map(|p| { - tracing::info_span!( - target: "walshadow::trace", - parent: p, - "catalog.gate", - lsn = record.source_lsn, - ) - }) - .unwrap_or_else(tracing::Span::none); - // Per-record sibling of `catalog.gate` for the batch view. - let catalog_span = trace_span!(sampled, "catalog", lsn = record.source_lsn); - let resolved = { - let mut cat = self.catalog.lock().await; - if self.rel_cache.is_none() { - self.rel_cache = Some(RelCache::new(cat.invalidation_epoch_handle())); - } - match cat - .relation_at(rfn, record.source_lsn) - .instrument(gate_span) - .instrument(catalog_span) - .await - { - Ok(r) => r, - Err(CatalogError::NotFoundByFilenode(_)) => { - // Filenode invisible at record LSN: created by - // this still-open xact (same-xact CREATE / - // TRUNCATE / rewrite generation) or gone - drop(cat); - if txn_xid == 0 { - self.stats - .catalog_not_found - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - return Ok(()); - } - return self.stash_invisible(record, rfn).await; - } - Err(e) => { - // ReplayTimeout means shadow stalled or the wire - // froze; silent skip would shed user-heap writes - // invisibly. Poison the stream so the daemon exits - // and the cursor resumes on next boot. - return Err(DecoderSinkError::from(e).into()); + let _ = sampled; + // Wait-free interval lookup: every record reaching this worker + // already has log coverage (capture runs inside the boundary + // hold, before successor bytes publish) + let (rel, rel_valid_from) = match self.log.descriptor_at_spanned(rfn, record.source_lsn) + { + Ok(pair) => pair, + Err(LookupResult::Present(_)) => { + unreachable!("spanned lookup returns Present via Ok") + } + // Foreign db / rel that died before the coverage horizon: + // counted row skip, never a stash or a fatal + Err(LookupResult::ForeignDb) => { + self.stats + .catalog_not_found + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + return Ok(()); + } + Err(LookupResult::NotCovered) + if record.source_lsn <= self.log.covered_through() || txn_xid == 0 => + { + self.stats + .catalog_not_found + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + return Ok(()); + } + // Filenode invisible at record LSN: created by this + // still-open xact (same-xact CREATE / TRUNCATE / rewrite + // generation) or already superseded — resolve at commit + Err(LookupResult::NotCovered | LookupResult::Dropped) => { + return self.stash_invisible(record, rfn).await; + } + // Inside a published interval: reachable on a re-read whose + // start sits past the dirty tree's first touch, so the + // record never took the defer arm. Marker-proven filenodes + // keep payload and meet the fence again at commit + // resolution; a markerless one would be dropped payload-free + // there, which is the silent row loss the fence exists to + // stop + Err(LookupResult::Ambiguous(a)) => { + let marker = self.buffer.lock().await.marker_lsn(rfn); + if marker.is_none() { + return Err(XactBufferError::StashAmbiguous { + rel_node: rfn.rel_node, + lsn: record.source_lsn, + from_lsn: a.from_lsn, + through_lsn: a.through_lsn, } + .into()); } - }; - if let Some(c) = self.rel_cache.as_mut() - && c.enabled() - { - c.insert(rfn, resolved.clone()); + return self.stash_invisible(record, rfn).await; + } + // Rotated away: every record on this rfn precedes the + // rotation (AccessExclusiveLock), so a Retired answer means + // the row never outlives the commit — skip + Err(LookupResult::Retired) => { + self.stats + .catalog_not_found + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + return Ok(()); } - resolved }; - // Empty in steady state - self.drain_schema_events(record.parsed.header.xact_id, record.source_lsn) - .await?; let decoded_set = { let _decode = decode_parent.as_ref().map(|p| { tracing::info_span!(target: "walshadow::trace", parent: p, "decode").entered() @@ -2607,7 +2913,13 @@ impl RecordSink for BufferingDecoderSink { .fetch_add(1, std::sync::atomic::Ordering::Relaxed); } } else { - buf.on_heap(decoded).await.map_err(SinkError::from)?; + buf.on_heap(DescribedHeap { + decoded, + descriptor: rel.clone(), + descriptor_valid_from: rel_valid_from, + }) + .await + .map_err(SinkError::from)?; } } Ok::<(), SinkError>(()) @@ -2640,16 +2952,6 @@ fn toast_record_tid(record: &walrus::pg::walparser::XLogRecord) -> Option<(u32, Some((blkno, offnum)) } -/// Drain queued [`SchemaEvent`]s; channel is unbounded + same-task -pub(crate) fn drain_pending_schema_events(rx: &SchemaEventRx) -> Vec { - let mut out = Vec::new(); - let mut guard = rx.lock().expect("schema event rx mutex poisoned"); - while let Ok(ev) = guard.try_recv() { - out.push(ev); - } - out -} - enum StashedToastOp { Chunk(ToastChunk), Delete(ToastDelete), @@ -2793,6 +3095,145 @@ fn toast_chunk_from_decoded( }) } +/// Raw-record fixtures shared with planner preflight tests: crafted WAL +/// bytes + direct `Ordinary` verdict injection ahead of the enable flip +#[cfg(test)] +pub(crate) mod raw_fixtures { + use super::*; + + pub(crate) fn int4_descriptor(rel_node: u32) -> Arc { + Arc::new(crate::schema::RelDescriptor { + rfn: RelFileNode { + spc_node: 1663, + db_node: 5, + rel_node, + }, + oid: rel_node, + toast_oid: 0, + namespace_oid: 2200, + rel_name: crate::schema::RelName::new("public", "copy_t"), + kind: 'r', + persistence: 'p', + replident: crate::schema::ReplIdent::Full { pk_attnums: None }, + attributes: vec![crate::schema::RelAttr { + attnum: 1, + name: "id".into(), + type_oid: crate::schema::INT4OID, + typmod: -1, + not_null: false, + dropped: false, + type_name: "int4".into(), + type_byval: true, + type_len: 4, + type_align: 'i', + type_storage: 'p', + missing_text: None, + }], + }) + } + + /// Heap2 MULTI_INSERT raw record, byte layout per PG + /// `heap_xlog_multi_insert` (mirrors the heap_decoder unit fixture) + pub(crate) fn multi_insert_raw(xid: u32, lsn: u64, rel_node: u32, values: &[i32]) -> RawRecord { + use crate::decode::heap_decoder::{XLH_INSERT_CONTAINS_NEW_TUPLE, XLOG_HEAP2_MULTI_INSERT}; + use crate::xact::spill::RawBlock; + let mut main_data = vec![XLH_INSERT_CONTAINS_NEW_TUPLE, 0]; + main_data.extend_from_slice(&(values.len() as u16).to_le_bytes()); + for off in 1..=values.len() as u16 { + main_data.extend_from_slice(&off.to_le_bytes()); + } + let mut data = Vec::new(); + for v in values { + data.extend_from_slice(&5u16.to_le_bytes()); // datalen: pad + int4 + data.extend_from_slice(&1u16.to_le_bytes()); // t_infomask2 = natts + data.extend_from_slice(&0u16.to_le_bytes()); // t_infomask + data.push(24); // t_hoff + data.push(0); // bitmap pad + data.extend_from_slice(&v.to_le_bytes()); + } + RawRecord { + xid, + rm: RmId::Heap2 as u8, + info: XLOG_HEAP2_MULTI_INSERT, + source_lsn: lsn, + page_magic: 0xD114, + main_data, + blocks: vec![RawBlock { + block_id: 0, + fork_flags: 0x20, + data_length: data.len() as u16, + image_length: 0, + hole_offset: 0, + hole_length: 0, + bimg_info: 0, + spc_node: 1663, + db_node: 5, + rel_node, + block_no: 0, + image: Vec::new(), + data, + }], + } + } + + /// `Ordinary` verdict for top xid 1, injected directly (no descriptor + /// log in these fixtures) + pub(crate) fn inject_ordinary( + b: &mut XactBuffer, + rfn: RelFileNode, + rel: Arc, + ) { + inject_ordinary_with_stats(b, rfn, rel, None); + } + + pub(crate) fn inject_ordinary_with_stats( + b: &mut XactBuffer, + rfn: RelFileNode, + rel: Arc, + stats: Option>, + ) { + inject_ordinary_fenced(b, rfn, rel, stats, Vec::new()); + } + + /// `Ordinary` verdict carrying a fence, as `resolve_stash` would attach + /// it for a filenode with overlapping ambiguity intervals + pub(crate) fn inject_ordinary_fenced( + b: &mut XactBuffer, + rfn: RelFileNode, + rel: Arc, + stats: Option>, + fence: Vec>, + ) { + let mut outcomes = HashMap::new(); + outcomes.insert( + rfn, + StashOutcome::Ordinary { + rel, + valid_from: 0x50, + fence, + }, + ); + b.pending_stash + .insert(1, StashResolution { outcomes, stats }); + } + + /// `[from, through)` interval over one filenode, the shape a physical + /// in-place verdict publishes + pub(crate) fn rfn_ambiguity( + rfn: RelFileNode, + from_lsn: u64, + through_lsn: u64, + ) -> Arc { + use crate::catalog::desc_log::{Ambiguity, AmbiguityReason, AmbiguityScope}; + Arc::new(Ambiguity { + scope: AmbiguityScope::Rfn(rfn), + from_lsn, + through_lsn, + reason: AmbiguityReason::UnknownMutationPosition, + }) + } +} + #[cfg(test)] mod tests { //! Catalog-free paths only. Commit-drain + detoast + @@ -2800,6 +3241,7 @@ mod tests { //! real shadow PG: they need `ShadowCatalog::relation_at`, and a //! unit-test stub catalog would duplicate the production cache. + use super::raw_fixtures::*; use super::*; use crate::decode::heap_decoder::{DecodedTuple, HeapOp, VARLENA_EXTSIZE_BITS}; use tempfile::tempdir; @@ -2857,21 +3299,78 @@ mod tests { } } - fn heap_with_value(xid: u32, lsn: u64, payload_size: usize) -> DecodedHeap { - DecodedHeap { + fn heap_with_value(xid: u32, lsn: u64, payload_size: usize) -> DescribedHeap { + DescribedHeap { + decoded: DecodedHeap { + rfn: RelFileNode { + spc_node: 1663, + db_node: 5, + rel_node: 16385, + }, + xid, + source_lsn: lsn, + op: HeapOp::Insert, + new: Some(DecodedTuple { + columns: vec![Some(ColumnValue::Bytea(vec![0u8; payload_size]))], + partial: false, + }), + old: None, + }, + descriptor: fixture_descriptor(16385), + descriptor_valid_from: 0x40, + } + } + + fn fixture_descriptor(rel_node: u32) -> Arc { + Arc::new(crate::schema::RelDescriptor { rfn: RelFileNode { spc_node: 1663, db_node: 5, - rel_node: 16385, + rel_node, }, + oid: rel_node, + toast_oid: 0, + namespace_oid: 2200, + rel_name: crate::schema::RelName::new("public", "buf_t"), + kind: 'r', + persistence: 'p', + replident: crate::schema::ReplIdent::Full { pk_attnums: None }, + attributes: vec![], + }) + } + + /// Heap UPDATE raw record, full new tuple (no prefix/suffix elision) + fn update_raw(xid: u32, lsn: u64, rel_node: u32, value: i32) -> RawRecord { + use crate::decode::heap_decoder::{SIZE_OF_HEAP_UPDATE, XLOG_HEAP_UPDATE}; + use crate::xact::spill::RawBlock; + let mut data = Vec::new(); + data.extend_from_slice(&1u16.to_le_bytes()); // natts + data.extend_from_slice(&0u16.to_le_bytes()); // infomask + data.push(24); // t_hoff + data.push(0); // bitmap pad + data.extend_from_slice(&value.to_le_bytes()); + RawRecord { xid, + rm: RmId::Heap as u8, + info: XLOG_HEAP_UPDATE, source_lsn: lsn, - op: HeapOp::Insert, - new: Some(DecodedTuple { - columns: vec![Some(ColumnValue::Bytea(vec![0u8; payload_size]))], - partial: false, - }), - old: None, + page_magic: 0xD114, + main_data: vec![0u8; SIZE_OF_HEAP_UPDATE], + blocks: vec![RawBlock { + block_id: 0, + fork_flags: 0x20, + data_length: data.len() as u16, + image_length: 0, + hole_offset: 0, + hole_length: 0, + bimg_info: 0, + spc_node: 1663, + db_node: 5, + rel_node, + block_no: 0, + image: Vec::new(), + data, + }], } } @@ -3100,6 +3599,7 @@ mod tests { rel_node: 16400, }, oid: 99, + toast_oid: 0, namespace_oid: 99, rel_name: RelName::new("pg_toast", "pg_toast_16385"), kind: 't', @@ -3209,6 +3709,7 @@ mod tests { rel_node: 16400, }, oid: 16400, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", "t"), kind: 'r', @@ -3567,7 +4068,7 @@ mod tests { // No HAS_INFO: body is just the 8-byte timestamp let ts = 0x0123_4567_89AB_CDEFi64; let body = ts.to_le_bytes(); - let p = parse_xact_payload(0x00, &body); + let p = parse_xact_payload(0x00, &body, 0xD116).unwrap(); assert_eq!(p.xact_time, ts); assert!(p.subxacts.is_empty()); } @@ -3585,7 +4086,7 @@ mod tests { body.extend_from_slice(&0xAAu32.to_le_bytes()); body.extend_from_slice(&0xBBu32.to_le_bytes()); body.extend_from_slice(&0xCCu32.to_le_bytes()); - let p = parse_xact_payload(XLOG_XACT_HAS_INFO, &body); + let p = parse_xact_payload(XLOG_XACT_HAS_INFO, &body, 0xD116).unwrap(); assert_eq!(p.xact_time, 42); assert_eq!(p.subxacts, vec![0xAA, 0xBB, 0xCC]); } @@ -3595,17 +4096,14 @@ mod tests { // HAS_INFO unset: parser must not consume bytes past the timestamp let mut body = 7i64.to_le_bytes().to_vec(); body.extend_from_slice(&[0xFF; 16]); - let p = parse_xact_payload(0x00, &body); + let p = parse_xact_payload(0x00, &body, 0xD116).unwrap(); assert_eq!(p.xact_time, 7); assert!(p.subxacts.is_empty()); } #[test] - fn parse_xact_payload_short_main_data_returns_default() { - let p = parse_xact_payload(XLOG_XACT_HAS_INFO, &[1, 2, 3, 4]); - assert_eq!(p.xact_time, 0); - assert!(p.subxacts.is_empty()); - assert_eq!(p.twophase_xid, None); + fn parse_xact_payload_short_main_data_errors() { + assert!(parse_xact_payload(XLOG_XACT_HAS_INFO, &[1, 2, 3, 4], 0xD116).is_err()); } #[test] @@ -3622,7 +4120,7 @@ mod tests { body.extend_from_slice(&[0u8; 16]); // SharedInvalidationMessage body.extend_from_slice(&0x1234u32.to_le_bytes()); // xl_xact_twophase body.extend_from_slice(b"gid\0"); - let p = parse_xact_payload(XLOG_XACT_HAS_INFO, &body); + let p = parse_xact_payload(XLOG_XACT_HAS_INFO, &body, 0xD116).unwrap(); assert_eq!(p.twophase_xid, Some(0x1234)); } @@ -3675,7 +4173,7 @@ mod tests { out.push(label(&batch.ordered_events[ev].event)); ev += 1; } - out.push(format!("h{}", h.source_lsn)); + out.push(format!("h{}", h.decoded.source_lsn)); } while ev < batch.ordered_events.len() { out.push(label(&batch.ordered_events[ev].event)); @@ -3684,6 +4182,552 @@ mod tests { out } + /// Events pushed out of LSN order (pump-side capture keys bias-early + /// valid_from, worker pushes at observe order) still drain LSN ASC. + #[tokio::test(flavor = "current_thread")] + async fn drain_sorts_out_of_order_event_pushes() { + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + b.on_heap(heap_with_value(1, 120, 16)).await.unwrap(); + // Arrival order 150, 100: 100 must still precede the heap@120 + b.on_schema_event(1, 150, dropped_event(9)); + b.on_schema_event(1, 100, dropped_event(7)); + let mut drain = b.drain_committed(1, 42, 0x2000, &[], false).await.unwrap(); + let mut order: Vec = Vec::new(); + while let Some(batch) = drain.next_batch(8, usize::MAX, None).await.unwrap() { + order.extend(flatten_batch(&batch)); + if batch.is_final { + break; + } + } + assert_eq!(order, vec!["e7", "h120", "e9"]); + drain.finish().await.unwrap(); + } + + /// One raw MULTI_INSERT (stashed under a subxact) fans out to every + /// tuple in order — each carrying the WRITER xid through the merge — + /// before anything past its LSN yields; an event at the same LSN keeps + /// the event-first tie break; a later heap follows the whole fanout. + /// `Ordinary` verdict injected directly (no descriptor log here) + #[tokio::test(flavor = "current_thread")] + async fn raw_multi_insert_fanout_orders_and_keeps_xid() { + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = int4_descriptor(16410); + let rfn = rel.rfn; + b.stash_raw(8, multi_insert_raw(8, 100, 16410, &[100, 200, 300])) + .await + .unwrap(); + b.on_heap(heap_with_value(1, 200, 16)).await.unwrap(); + b.on_schema_event(1, 100, dropped_event(7)); + inject_ordinary(&mut b, rfn, rel); + + let mut drain = b.drain_committed(1, 42, 0x2000, &[8], false).await.unwrap(); + let batch = drain + .next_batch(8, usize::MAX, None) + .await + .unwrap() + .expect("one slice"); + assert!(batch.is_final); + assert_eq!(batch.ordered_events.len(), 1); + assert_eq!( + batch.ordered_events[0].heap_idx, 0, + "same-LSN event precedes the fanout" + ); + assert_eq!(batch.heaps.len(), 4, "3 fanned-out tuples + later heap"); + for (i, expected) in [100i32, 200, 300].iter().enumerate() { + let h = &batch.heaps[i]; + assert_eq!(h.decoded.source_lsn, 100); + assert_eq!(h.decoded.xid, 8, "writer xid survives subxact merge"); + assert_eq!(h.descriptor_valid_from, 0x50); + let new = h.decoded.new.as_ref().unwrap(); + assert_eq!(new.columns[0], Some(ColumnValue::Int4(*expected))); + } + assert_eq!(batch.heaps[3].decoded.source_lsn, 200, "fanout first"); + drain.finish().await.unwrap(); + } + + /// Decode counters label the record's op; pending gauges track the + /// fanout queue and return to zero once every tuple yields + #[tokio::test(flavor = "current_thread")] + async fn raw_fanout_counts_decode_and_pending_gauges() { + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = int4_descriptor(16415); + let rfn = rel.rfn; + b.stash_raw(1, multi_insert_raw(1, 100, 16415, &[1, 2, 3])) + .await + .unwrap(); + let stats = Arc::new(EmitterStats::default()); + inject_ordinary_with_stats(&mut b, rfn, rel, Some(stats.clone())); + let mut drain = b.drain_committed(1, 42, 0x2000, &[], false).await.unwrap(); + let batch = drain + .next_batch(2, usize::MAX, None) + .await + .unwrap() + .expect("first slice"); + assert_eq!(batch.heaps.len(), 2); + assert_eq!(b.raw_pending_rows(), 1, "third fanned tuple still queued"); + assert!(b.raw_pending_bytes() > 0); + let ops = stats.raw_decode_ordinary_ops.load(); + assert_eq!(ops[5], 1, "one multi_insert record decoded"); + assert_eq!(stats.raw_decode_rows_ops.load()[5], 3, "three rows fanned"); + let batch = drain + .next_batch(8, usize::MAX, None) + .await + .unwrap() + .expect("final slice"); + assert!(batch.is_final); + assert_eq!(batch.heaps.len(), 1); + assert_eq!(b.raw_pending_rows(), 0); + assert_eq!(b.raw_pending_bytes(), 0); + drain.finish().await.unwrap(); + } + + /// MULTI_INSERT lacking `XLH_INSERT_CONTAINS_NEW_TUPLE` cannot prove its + /// tuple data survived FPIs: typed ImageOnly, drain halts + #[tokio::test(flavor = "current_thread")] + async fn raw_image_only_fails_closed() { + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = int4_descriptor(16411); + let rfn = rel.rfn; + let mut raw = multi_insert_raw(1, 100, 16411, &[1]); + raw.main_data[0] = 0; + b.stash_raw(1, raw).await.unwrap(); + inject_ordinary(&mut b, rfn, rel); + let mut drain = b.drain_committed(1, 42, 0x2000, &[], false).await.unwrap(); + let Err(err) = drain.next_batch(8, usize::MAX, None).await else { + panic!("expected fail-closed error"); + }; + assert!( + matches!( + err, + XactBufferError::OrdinaryFailClosed { + reason: FailClosedReason::ImageOnly, + .. + } + ), + "{err}" + ); + } + + /// The fence is per record: a stashed record inside a published interval + /// fails closed at its own LSN while a sibling past `through_lsn` decodes. + /// Resolution happens at the commit's `next_lsn`, which no interval + /// covers, so this is the only place the fence can apply + #[tokio::test(flavor = "current_thread")] + async fn fence_fails_closed_per_record() { + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = int4_descriptor(16420); + let rfn = rel.rfn; + // Inside [100, 200): no descriptor proven to read it + b.stash_raw(1, multi_insert_raw(1, 150, 16420, &[1])) + .await + .unwrap(); + inject_ordinary_fenced(&mut b, rfn, rel, None, vec![rfn_ambiguity(rfn, 100, 200)]); + let mut drain = b.drain_committed(1, 42, 0x2000, &[], false).await.unwrap(); + let Err(err) = drain.next_batch(8, usize::MAX, None).await else { + panic!("expected fenced record to fail closed"); + }; + assert!( + matches!( + err, + XactBufferError::StashAmbiguous { + lsn: 150, + from_lsn: 100, + through_lsn: 200, + .. + } + ), + "{err}" + ); + } + + /// `resolve_stash`'s Ordinary edge against a real log, the way capture + /// leaves it after a physical in-place verdict: `Present` at the commit's + /// `next_lsn`, siblings fencing `[first_touch, next_lsn)`. Resolution + /// queries at `next_lsn`, which no interval covers, so the fence has to + /// travel with the verdict for the drain to see it + #[tokio::test(flavor = "current_thread")] + async fn resolve_stash_attaches_fence_from_log() { + use crate::catalog::desc_log::{ + AmbiguityReason, AmbiguityScope, BatchRecord, DescLogIdentity, LogEntry, LogValue, + }; + let log_dir = tempdir().unwrap(); + let log = DescriptorLog::open( + log_dir.path(), + DescLogIdentity { + pg_major: 17, + system_id: "7".into(), + timeline: 1, + db_oid: 5, + wal_seg_size: 16 * 1024 * 1024, + }, + ) + .await + .unwrap(); + let rel = int4_descriptor(16430); + let mut batch = BatchRecord { + captured_at: 0x300, + commit_lsn: 0x2F0, + observations: Vec::new(), + ambiguities: Vec::new(), + entries: vec![Arc::new(LogEntry { + valid_from: 0x300, + oid: rel.oid, + rfn: rel.rfn, + value: LogValue::Present(rel.clone()), + })], + }; + for scope in [AmbiguityScope::Rfn(rel.rfn), AmbiguityScope::Oid(rel.oid)] { + batch.ambiguities.push(Arc::new(Ambiguity { + scope, + from_lsn: 0x100, + through_lsn: 0x300, + reason: AmbiguityReason::UnknownMutationPosition, + })); + } + log.append_batch(batch).await.unwrap(); + + let spill_dir = tempdir().unwrap(); + let buffer = Arc::new(Mutex::new( + XactBuffer::new(cfg(spill_dir.path().to_path_buf())).unwrap(), + )); + buffer + .lock() + .await + .stash_raw(1, multi_insert_raw(1, 0x200, 16430, &[1])) + .await + .unwrap(); + resolve_stash( + &buffer, + &log, + 1, + &[], + 0x300, + Arc::new(EmitterStats::default()), + ) + .await + .unwrap(); + let mut b = buffer.lock().await; + let mut drain = b.drain_committed(1, 42, 0x2F0, &[], false).await.unwrap(); + let Err(err) = drain.next_batch(8, usize::MAX, None).await else { + panic!("expected the log's interval to fence the stashed record"); + }; + assert!( + matches!( + err, + XactBufferError::StashAmbiguous { + lsn: 0x200, + from_lsn: 0x100, + through_lsn: 0x300, + .. + } + ), + "{err}" + ); + } + + /// Half-open: `through_lsn` is the first LSN the final descriptor proves, + /// so a record there decodes and one just below does not + #[tokio::test(flavor = "current_thread")] + async fn fence_admits_record_at_through_lsn() { + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = int4_descriptor(16421); + let rfn = rel.rfn; + b.stash_raw(1, multi_insert_raw(1, 200, 16421, &[7, 8])) + .await + .unwrap(); + inject_ordinary_fenced( + &mut b, + rfn, + rel, + None, + vec![rfn_ambiguity(rfn, 100, 200), rfn_ambiguity(rfn, 20, 40)], + ); + 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"); + assert_eq!(batch.heaps.len(), 2, "record at through_lsn decodes"); + drain.finish().await.unwrap(); + } + + /// Draining stashed raws with no verdict installed would send every one + /// through the discard arm, fence included — fail closed instead + #[tokio::test(flavor = "current_thread")] + async fn missing_stash_resolution_fails_closed() { + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + b.stash_raw(1, multi_insert_raw(1, 100, 16422, &[1])) + .await + .unwrap(); + let Err(err) = b.drain_committed(1, 42, 0x2000, &[], false).await else { + panic!("expected fail-closed with no resolution installed"); + }; + assert!( + matches!(err, XactBufferError::MissingStashResolution { top_xid: 1 }), + "{err}" + ); + } + + /// An aborted tree's resolution must not linger for the next xact that + /// reuses the xid: it would fold raws under a foreign descriptor + fence + #[tokio::test(flavor = "current_thread")] + async fn abort_drops_installed_resolution() { + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = int4_descriptor(16423); + let rfn = rel.rfn; + b.stash_raw(1, multi_insert_raw(1, 100, 16423, &[1])) + .await + .unwrap(); + inject_ordinary(&mut b, rfn, rel); + b.abort(1, 0x1000, &[]).await.unwrap(); + assert!(b.pending_stash.is_empty()); + } + + /// INPLACE mutates tuple bytes with no decode shape: typed reject, not + /// a silent skip + #[tokio::test(flavor = "current_thread")] + async fn raw_unsupported_op_fails_closed() { + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = int4_descriptor(16412); + let rfn = rel.rfn; + let mut raw = multi_insert_raw(1, 100, 16412, &[1]); + raw.rm = RmId::Heap as u8; + raw.info = crate::decode::heap_decoder::XLOG_HEAP_INPLACE; + b.stash_raw(1, raw).await.unwrap(); + inject_ordinary(&mut b, rfn, rel); + let mut drain = b.drain_committed(1, 42, 0x2000, &[], false).await.unwrap(); + let Err(err) = drain.next_batch(8, usize::MAX, None).await else { + panic!("expected fail-closed error"); + }; + assert!( + matches!( + err, + XactBufferError::OrdinaryFailClosed { + reason: FailClosedReason::UnsupportedOperation, + .. + } + ), + "{err}" + ); + } + + /// Raw record whose writer xid is outside the owning xact + subxacts + /// (spill corruption / buffer-key drift shape): merge ownership check + /// fails the drain before the foreign row can emit + #[tokio::test(flavor = "current_thread")] + async fn raw_foreign_xid_fails_closed() { + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = int4_descriptor(16417); + let rfn = rel.rfn; + b.stash_raw(1, multi_insert_raw(99, 100, 16417, &[5])) + .await + .unwrap(); + inject_ordinary(&mut b, rfn, rel); + let mut drain = b.drain_committed(1, 42, 0x2000, &[], false).await.unwrap(); + let Err(err) = drain.next_batch(8, usize::MAX, None).await else { + panic!("expected foreign-xid error"); + }; + assert!( + matches!(err, XactBufferError::ForeignXid { xid: 99, top: 1 }), + "{err}" + ); + } + + /// Page maintenance stashed alongside user rows (heap2 PRUNE, heap LOCK) + /// skips without failing the drain; the user row still lands + #[tokio::test(flavor = "current_thread")] + async fn raw_maintenance_ops_skip() { + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = int4_descriptor(16413); + let rfn = rel.rfn; + let mut prune = multi_insert_raw(1, 90, 16413, &[1]); + prune.info = 0x10; // XLOG_HEAP2_PRUNE* + let mut lock = multi_insert_raw(1, 95, 16413, &[1]); + lock.rm = RmId::Heap as u8; + lock.info = crate::decode::heap_decoder::XLOG_HEAP_LOCK; + b.stash_raw(1, prune).await.unwrap(); + b.stash_raw(1, lock).await.unwrap(); + b.stash_raw(1, multi_insert_raw(1, 100, 16413, &[7])) + .await + .unwrap(); + inject_ordinary(&mut b, rfn, rel); + 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"); + assert!(batch.is_final); + assert_eq!(batch.heaps.len(), 1, "maintenance skipped, row kept"); + let new = batch.heaps[0].decoded.new.as_ref().unwrap(); + assert_eq!(new.columns[0], Some(ColumnValue::Int4(7))); + drain.finish().await.unwrap(); + } + + /// Prefix-elided UPDATE needs a predecessor image raw decode lacks: + /// typed PartialUpdate. PG only elides below wal_level=logical + #[tokio::test(flavor = "current_thread")] + async fn raw_partial_update_fails_closed() { + use crate::decode::heap_decoder::{ + SIZE_OF_HEAP_UPDATE, XLH_UPDATE_PREFIX_FROM_OLD, XLOG_HEAP_UPDATE, + }; + use crate::xact::spill::RawBlock; + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = int4_descriptor(16414); + let rfn = rel.rfn; + let mut main_data = vec![0u8; SIZE_OF_HEAP_UPDATE]; + main_data[7] = XLH_UPDATE_PREFIX_FROM_OLD; + // block 0: [prefixlen=4 covers the whole int4][xl_heap_header][pad] + let mut data = Vec::new(); + data.extend_from_slice(&4u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); // natts + data.extend_from_slice(&0u16.to_le_bytes()); // infomask + data.push(24); // t_hoff + data.push(0); // bitmap pad + let raw = RawRecord { + xid: 1, + rm: RmId::Heap as u8, + info: XLOG_HEAP_UPDATE, + source_lsn: 100, + page_magic: 0xD114, + main_data, + blocks: vec![RawBlock { + block_id: 0, + fork_flags: 0x20, + data_length: data.len() as u16, + image_length: 0, + hole_offset: 0, + hole_length: 0, + bimg_info: 0, + spc_node: 1663, + db_node: 5, + rel_node: 16414, + block_no: 0, + image: Vec::new(), + data, + }], + }; + b.stash_raw(1, raw).await.unwrap(); + inject_ordinary(&mut b, rfn, rel); + let mut drain = b.drain_committed(1, 42, 0x2000, &[], false).await.unwrap(); + let Err(err) = drain.next_batch(8, usize::MAX, None).await else { + panic!("expected fail-closed error"); + }; + assert!( + matches!( + err, + XactBufferError::OrdinaryFailClosed { + reason: FailClosedReason::PartialUpdate, + .. + } + ), + "{err}" + ); + } + + /// COPY fanout precedes a later UPDATE raw: LSN order holds across the + /// pending-queue / merge-source boundary + #[tokio::test(flavor = "current_thread")] + async fn raw_multi_insert_then_update_lsn_order() { + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = int4_descriptor(16415); + let rfn = rel.rfn; + b.stash_raw(1, multi_insert_raw(1, 100, 16415, &[1, 2, 3])) + .await + .unwrap(); + b.stash_raw(1, update_raw(1, 150, 16415, 9)).await.unwrap(); + inject_ordinary(&mut b, rfn, rel); + 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"); + assert!(batch.is_final); + let ops: Vec<_> = batch + .heaps + .iter() + .map(|h| (h.decoded.source_lsn, h.decoded.op)) + .collect(); + assert_eq!( + ops, + vec![ + (100, HeapOp::Insert), + (100, HeapOp::Insert), + (100, HeapOp::Insert), + (150, HeapOp::Update), + ], + ); + assert_eq!( + batch.heaps[3].decoded.new.as_ref().unwrap().columns[0], + Some(ColumnValue::Int4(9)), + ); + 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 + #[tokio::test(flavor = "current_thread")] + async fn raw_xid_survives_memory_and_spill() { + let tmp = tempdir().unwrap(); + let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); + let rel = int4_descriptor(16416); + let rfn = rel.rfn; + // ~3.6 KiB of tuple data blows the 1 KiB budget: subxact 8 spills + let big: Vec = (0..300).collect(); + b.stash_raw(8, multi_insert_raw(8, 100, 16416, &big)) + .await + .unwrap(); + b.stash_raw(9, multi_insert_raw(9, 200, 16416, &[7])) + .await + .unwrap(); + let spilled: Vec<(u32, bool)> = b + .inflight_snapshot() + .iter() + .map(|e| (e.xid, e.spilled)) + .collect(); + assert!(spilled.contains(&(8, true)), "{spilled:?}"); + assert!(spilled.contains(&(9, false)), "{spilled:?}"); + inject_ordinary(&mut b, rfn, rel); + let mut drain = b + .drain_committed(1, 42, 0x2000, &[8, 9], false) + .await + .unwrap(); + let mut heaps = Vec::new(); + while let Some(batch) = drain.next_batch(64, usize::MAX, None).await.unwrap() { + heaps.extend( + batch + .heaps + .iter() + .map(|h| (h.decoded.source_lsn, h.decoded.xid)), + ); + if batch.is_final { + break; + } + } + assert_eq!(heaps.len(), 301); + assert!(heaps[..300].iter().all(|&(l, x)| l == 100 && x == 8)); + assert_eq!(heaps[300], (200, 9)); + assert!(b.drain_resident_peak() > 0, "fanout charged the gauge"); + drain.finish().await.unwrap(); + assert_eq!(b.drain_resident_bytes(), 0, "pending accounting released"); + } + /// Batched drain must reproduce the serial merge order: spilled + in-mem /// across top/subxact by `source_lsn` ASC, events winning ties, trailing /// event after the last heap. `is_final` only on the last slice. @@ -3806,8 +4850,8 @@ mod tests { let mut b = XactBuffer::new(cfg(tmp.path().to_path_buf())).unwrap(); b.on_toast_chunk(chunk(55, 0, 100, b"ab"), 9).await.unwrap(); let mut trunc = heap_with_value(9, 110, 16); - trunc.op = HeapOp::Truncate; - trunc.new = None; + trunc.decoded.op = HeapOp::Truncate; + trunc.decoded.new = None; b.on_heap(trunc).await.unwrap(); b.on_toast_chunk(chunk(56, 0, 120, b"cd"), 9).await.unwrap(); b.on_schema_event(9, 130, dropped_event(7)); diff --git a/tests/bin_stream_e2e.rs b/tests/bin_stream_e2e.rs index 50891fb1..74419b8d 100644 --- a/tests/bin_stream_e2e.rs +++ b/tests/bin_stream_e2e.rs @@ -427,6 +427,17 @@ async fn bin_stream_replicates_segments_and_serves_metrics() { "shadow replay {observed:X} < target {target:X}", ); + // 8b. CREATE TABLE bs.t2's commit is a catalog boundary: the pump + // must have parked once and released via shadow replay (info + // line at the walshadow::boundary_hold target). Metrics can't + // carry this assertion — the endpoint dies with the + // max-segments exit before a poll reliably observes it. + let stderr = fs::read_to_string(&stderr_path).unwrap_or_default(); + assert!( + stderr.contains("catalog boundary released"), + "catalog commit never parked the pump\n--- daemon stderr ---\n{stderr}", + ); + // 9a. Catalog mirroring: bs.t2 was created during the // workload — DDL records pass through the filter, shadow // replays them, the table must exist on both sides with diff --git a/tests/bootstrap_pipeline_ch.rs b/tests/bootstrap_pipeline_ch.rs index 1d12d8b4..31451c90 100644 --- a/tests/bootstrap_pipeline_ch.rs +++ b/tests/bootstrap_pipeline_ch.rs @@ -28,7 +28,7 @@ use walshadow::backup_page_walk::{BackfillTuple, CatalogMap}; use walshadow::ch::CompressionChoice; use walshadow::ch_emitter::{EmitterConfig, EmitterStats}; use walshadow::heap_decoder::ColumnValue; -use walshadow::mapping::{ColumnMapping, MappingHandle, TableMapping, TableTarget}; +use walshadow::mapping::{ColumnMapping, TableMapping, TableTarget}; use walshadow::pipeline::batcher::BatcherMsg; use walshadow::pipeline::{Fatal, bootstrap, tail}; use walshadow::schema::{RelAttr, RelDescriptor, RelName, ReplIdent}; @@ -49,6 +49,7 @@ fn rel(rel_node: u32, name: &str) -> Arc { rel_node, }, oid: rel_node, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", name), kind: 'r', @@ -142,7 +143,7 @@ async fn bootstrap_tail_fans_out_n2() { let mut catalog = CatalogMap::new(); catalog.insert(rel(16400, "foo")); catalog.insert(rel(16401, "baz")); - let mapping: MappingHandle = Arc::new(tokio::sync::RwLock::new(cfg.tables.clone())); + let mapping = walshadow::mapping::mapping_handle(cfg.tables.clone()); let stats = Arc::new(EmitterStats::default()); let emitter_ack = Arc::new(AtomicU64::new(0)); @@ -181,6 +182,8 @@ async fn bootstrap_tail_fans_out_n2() { std::env::temp_dir().join("ws-bootstrap-ch-unused.bin"), walshadow::spool::DEFERRED_SPOOL_MEM_MAX, ), + false, + None, )); let outcome = drain.await.expect("drain join").expect("drain ok"); assert_eq!(outcome.next_seq, 2, "one seq per rfn"); diff --git a/tests/common/inproc_harness.rs b/tests/common/inproc_harness.rs index a5ced75d..92dbd9f4 100644 --- a/tests/common/inproc_harness.rs +++ b/tests/common/inproc_harness.rs @@ -35,9 +35,7 @@ use walrus::pg::replication::tls::{SslMode, TlsParams}; use walshadow::ch::CompressionChoice; use walshadow::ch_ddl::{DdlApplicator, DdlConfig}; use walshadow::ch_emitter::{EmitterConfig, EmitterStats}; -use walshadow::mapping::{ - ColumnMapping, MappingHandle, NamespaceMapping, TableMapping, TableTarget, -}; +use walshadow::mapping::{ColumnMapping, NamespaceMapping, TableMapping, TableTarget}; use walshadow::pg::socket_conninfo; use walshadow::pipeline::reorder::ReorderSink; use walshadow::pipeline::{PipelineConfig, PipelineHandle, TailKind}; @@ -109,6 +107,8 @@ pub fn append_source_conf(sh: &Shadow) { writeln!(f, "\n# walshadow inproc source overrides").unwrap(); writeln!(f, "wal_level = logical").unwrap(); writeln!(f, "max_wal_senders = 4").unwrap(); + // 2PC coverage (COMMIT PREPARED drains under the prepared xid) + writeln!(f, "max_prepared_transactions = 4").unwrap(); } pub struct StopOnDrop<'a> { @@ -483,6 +483,12 @@ pub struct PipelineSinks { pub metrics: MetricsRecordSink, pub decoder: BufferingDecoderSink, pub reorder: ReorderSink, + /// Descriptor capture at catalog boundaries. The daemon runs it inside + /// the boundary hold; the synchronous harness has no gate, so + /// `wait_for_replay` stands in before capturing (nothing past the + /// commit has been pumped yet — serial record cadence) + pub capture: Option, + pub catalog: Arc>, } impl RecordSink for PipelineSinks { @@ -493,6 +499,17 @@ impl RecordSink for PipelineSinks { Box> + Send + 'a>, > { Box::pin(async move { + if let (Some(capture), Some(info)) = (&self.capture, &record.boundary_info) { + self.catalog + .lock() + .await + .wait_for_replay(record.next_lsn) + .await + .map_err(|e| SinkError::Other(format!("harness boundary wait: {e}")))?; + capture + .capture_boundary(info, record.source_lsn, record.next_lsn) + .await?; + } self.metrics.on_record(record).await?; self.decoder.on_record(record).await?; self.reorder.on_record(record).await?; @@ -518,6 +535,9 @@ pub struct Pipeline { /// Same `EmitterStats` Arc the daemon exports to Prometheus; lets tests /// assert the parallel path keeps the emitter counters live. pub stats: Arc, + /// Live descriptor-log handle: interval-semantics assertions against + /// real captured history. + pub desc_log: Arc, } impl Pipeline { @@ -620,17 +640,6 @@ async fn build_pipeline_inner( .expect("connect shadow catalog"); let catalog = Arc::new(Mutex::new(catalog)); - let inv_epoch = Arc::new(AtomicU64::new(0)); - catalog - .lock() - .await - .set_invalidation_epoch(inv_epoch.clone()); - - // Xid-keyed DROP-sweep arming for the reorder coordinator's - // commit-boundary sweep. Only the DDL path needs it (mirrors - // bin/stream.rs). - let pending_sweeps = walshadow::catalog_tracker::PendingSweeps::new(); - feed.start_physical_replication(None, aligned, ident.timeline) .await .expect("START_REPLICATION"); @@ -658,33 +667,79 @@ async fn build_pipeline_inner( ); } - // DDL wiring (mirrors bin/stream.rs --ch-config). Fold namespace / + // DDL wiring (mirrors bin/stream.rs --ch-config): fold namespace / // drop-strategy overrides into the emitter config *before* the - // applicator reads it, seed the schema-diff baseline for mapped - // relations so a pinned table's first ALTER surfaces as Changed, and - // subscribe the decoder to the catalog's schema-event channel so - // ALTER/CREATE/DROP reach the reorder coordinator as `ordered_events`. - let mut schema_events: Option = None; + // applicator reads it. Schema events come solely from descriptor + // capture at catalog boundaries. if let Some(d) = ddl.as_ref() { emitter_cfg.namespaces = d.namespaces.clone(); if let Some(s) = &d.drop_table_strategy { emitter_cfg.drop_table_strategy = s.clone(); } - let names: Vec = emitter_cfg.tables.keys().cloned().collect(); - catalog + } + + // Descriptor log + capture (mirrors bin/stream.rs): seed the baseline + // snapshot at the boot head, capture at boundaries thereafter. + let shadow_db_oid = catalog + .lock() + .await + .current_database_oid() + .await + .expect("shadow db oid"); + stream.filter_mut().set_inval_db(shadow_db_oid); + let smgr_markers = stream.filter_mut().smgr_markers(); + let desc_log = Arc::new( + walshadow::desc_log::DescriptorLog::open( + &spill_dir, + walshadow::desc_log::DescLogIdentity { + pg_major: (feed.server_version_num() / 10000) as u32, + system_id: ident.sysid.clone(), + timeline: ident.timeline, + db_oid: shadow_db_oid, + wal_seg_size: WAL_SEG_SIZE as u32, + }, + ) + .await + .expect("open descriptor log"), + ); + if desc_log.is_empty() { + let (replay_lsn, descs) = catalog .lock() .await - .seed_baseline(&names) + .fetch_all_descriptors() .await - .expect("seed schema-diff baseline"); - let rx = catalog.lock().await.subscribe(); - schema_events = Some(Arc::new(std::sync::Mutex::new(rx))); + .expect("descriptor log boot seed"); + let covered_through = ident.xlogpos.max(replay_lsn); + let entries = descs + .into_iter() + .map(|d| { + Arc::new(walshadow::desc_log::LogEntry { + valid_from: aligned, + oid: d.oid, + rfn: d.rfn, + value: walshadow::desc_log::LogValue::Present(Arc::new(d)), + }) + }) + .collect(); + desc_log + .seed( + walshadow::desc_log::BatchRecord { + captured_at: covered_through, + commit_lsn: 0, + observations: Vec::new(), + ambiguities: Vec::new(), + entries, + }, + covered_through, + ) + .await + .expect("seed descriptor log"); } tune(&mut emitter_cfg); // SIGHUP-reloadable mapping shared by the DDL applicator + decode pool. - let mapping: MappingHandle = Arc::new(tokio::sync::RwLock::new(emitter_cfg.tables.clone())); + let mapping = walshadow::mapping::mapping_handle(emitter_cfg.tables.clone()); // Resolver seeds the DDL applicator's live config layers and takes the // runtime-config WAL apply (harness injects no config writes, so it stays // at the boot snapshot). No SIGHUP task here. @@ -694,7 +749,6 @@ async fn build_pipeline_inner( None, toml::Table::new(), mapping.clone(), - inv_epoch.clone(), ); let ddl_cfg = DdlConfig::from_resolved( &config_rx.borrow(), @@ -704,7 +758,6 @@ async fn build_pipeline_inner( let applicator = DdlApplicator::new(&emitter_cfg, ddl_cfg, mapping.clone(), config_rx.clone()) .await .expect("ddl applicator init") - .with_invalidation_epoch(inv_epoch.clone()) .with_resolver(config_resolver.clone()); let stats = Arc::new(EmitterStats::default()); let emitter_ack = Arc::new(AtomicU64::new(0)); @@ -732,6 +785,7 @@ async fn build_pipeline_inner( mapping.clone(), stats.clone(), catalog.clone(), + desc_log.clone(), &spill_dir, Some(config_rx.clone()), None, @@ -752,8 +806,7 @@ async fn build_pipeline_inner( tail: TailKind::ClickHouse, buffer: xact_buffer.clone(), subxact_tracker: Arc::new(Mutex::new(SubxactTracker::new())), - schema_events: schema_events.clone(), - pending_sweeps: ddl.as_ref().map(|_| pending_sweeps.clone()), + log: desc_log.clone(), stats: stats.clone(), span_registry: None, config_resolver: Some(config_resolver.clone()), @@ -772,19 +825,27 @@ async fn build_pipeline_inner( .flush_due_retires() .await .expect("boot flush of due toast-mirror retires"); + reorder + .apply_boot_events(desc_log.active_present_at(ident.xlogpos), ident.xlogpos) + .await + .expect("boot Added pass over descriptor log"); - let mut decoder = BufferingDecoderSink::new(catalog, xact_buffer.clone()) - .with_catalog_signals(inv_epoch, ddl.as_ref().map(|_| pending_sweeps.clone())); - if let Some(rx) = &schema_events { - decoder = decoder.with_schema_events(rx.clone()); - } + let mut decoder = BufferingDecoderSink::new(desc_log.clone(), xact_buffer.clone()); if let Some(schema) = ddl.as_ref().and_then(|d| d.config_schema.as_deref()) { decoder = decoder.with_config_schema(Arc::from(schema)); } + let capture = walshadow::catalog_capture::CatalogCapture::new( + desc_log.clone(), + catalog.clone(), + xact_buffer.clone(), + smgr_markers, + ); let sinks = PipelineSinks { metrics: MetricsRecordSink::default(), decoder, reorder, + capture: Some(capture), + catalog, }; let segment_sink = DirSegmentSink::new(shadow_filter_dir.to_path_buf()).expect("open shadow filter dir"); @@ -801,6 +862,7 @@ async fn build_pipeline_inner( ack: emitter_ack, resume_floor, stats, + desc_log, } } @@ -817,6 +879,31 @@ pub async fn pump_until( segments_needed: u64, deadline: Duration, ) -> u64 { + pump_until_res( + feed, + stream, + record_sink, + segment_sink, + chunk_buf, + segments_needed, + deadline, + ) + .await + .expect("push") +} + +/// `pump_until` surfacing a sink error instead of panicking, for drills +/// asserting a fail-closed path (fence, unmodelled op) +#[allow(clippy::too_many_arguments)] +pub async fn pump_until_res( + feed: &mut SourceFeed, + stream: &mut WalStream, + record_sink: &mut S, + segment_sink: &mut DirSegmentSink, + chunk_buf: &mut Vec, + segments_needed: u64, + deadline: Duration, +) -> std::result::Result { let end = Instant::now() + deadline; let mut segments_shipped = 0u64; let mut prev = stream.dispatched_lsn(); @@ -836,14 +923,14 @@ pub async fn pump_until( stream .push(chunk.start_lsn, chunk.data, record_sink, segment_sink) .await - .expect("push"); + .map_err(|e| format!("{e:#}"))?; let now = stream.dispatched_lsn(); if now != prev { segments_shipped += (now - prev) / WAL_SEG_SIZE; prev = now; } } - segments_shipped + Ok(segments_shipped) } /// Drive the WAL pump until `segments_needed` segments have shipped or @@ -865,6 +952,24 @@ pub async fn pump_segments( .await } +/// `pump_segments` surfacing the sink error a fail-closed path raises +pub async fn pump_segments_res( + pipeline: &mut Pipeline, + segments_needed: u64, + deadline: Duration, +) -> std::result::Result { + pump_until_res( + &mut pipeline.feed, + &mut pipeline.stream, + &mut pipeline.sinks, + &mut pipeline.segment_sink, + &mut pipeline.chunk_buf, + segments_needed, + deadline, + ) + .await +} + /// Poll `sql` until it returns `want` or the deadline passes (commit drains /// land asynchronously after a segment ships). pub async fn wait_query(ch: &ChServer, sql: &str, want: &str, what: &str) { @@ -882,6 +987,43 @@ pub async fn wait_query(ch: &ChServer, sql: &str, want: &str, what: &str) { } } +/// Pipe multi-line SQL through one delayed psql session, preserving explicit transactions +pub fn spawn_txn(source: &Shadow, body: &str) -> std::thread::JoinHandle<()> { + let sock = source.config().socket_dir.clone(); + let port = source.config().port; + let sql = body.to_owned(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + let mut child = Command::new("psql") + .args([ + "-h", + sock.to_str().unwrap(), + "-p", + &port.to_string(), + "-U", + "postgres", + "-d", + "postgres", + "-v", + "ON_ERROR_STOP=1", + "-f", + "-", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn psql"); + child + .stdin + .as_mut() + .expect("stdin piped") + .write_all(sql.as_bytes()) + .unwrap(); + let _ = child.wait(); + }) +} + /// Driver thread that fires a sequence of `-c` statements at the source /// after a brief delay, so the test's `pump_segments` is already /// listening when commits land. diff --git a/tests/composite_pkey.rs b/tests/composite_pkey.rs index 96d20c4f..4a56ed23 100644 --- a/tests/composite_pkey.rs +++ b/tests/composite_pkey.rs @@ -5,7 +5,7 @@ #[path = "common/inproc_harness.rs"] mod fx; -use std::process::{Command, Stdio}; +use fx::spawn_txn; use std::time::Duration; use walshadow::mapping::ColumnMapping; @@ -81,45 +81,6 @@ fn create_ch_dest(ch: &fx::ChServer) { .expect("create dest table"); } -fn spawn_txn(source: &Shadow, body: &str) -> std::thread::JoinHandle<()> { - let sock = source.config().socket_dir.clone(); - let port = source.config().port; - let sql = body.to_owned(); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(200)); - let mut child = Command::new("psql") - .args([ - "-h", - sock.to_str().unwrap(), - "-p", - &port.to_string(), - "-U", - "postgres", - "-d", - "postgres", - "-v", - "ON_ERROR_STOP=1", - "-f", - "-", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn psql"); - { - use std::io::Write as _; - child - .stdin - .as_mut() - .expect("stdin piped") - .write_all(sql.as_bytes()) - .unwrap(); - } - let _ = child.wait(); - }) -} - async fn run_drill( slot: PortSlot, app_name: &str, diff --git a/tests/control_plane_e2e.rs b/tests/control_plane_e2e.rs index b011c893..76c544f4 100644 --- a/tests/control_plane_e2e.rs +++ b/tests/control_plane_e2e.rs @@ -57,6 +57,14 @@ const P3: Ports = Ports { metrics: 17455, walsender: 17456, }; +const P4: Ports = Ports { + source: 17461, + shadow: 17462, + ch_tcp: 17469, + ch_http: 17470, + metrics: 17475, + walsender: 17476, +}; /// Running daemon + its source PG + CH, with the paths the tests poke. struct Harness { @@ -474,7 +482,6 @@ async fn pause_resume_via_ctl_and_sighup_no_restart() { } } -#[ignore] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn live_table_opt_in_auto_creates_on_reload() { if !gated() { @@ -580,3 +587,81 @@ async fn apply_preserves_previously_pinned_table() { panic!("{e:#}\n--- daemon stderr ---\n{stderr}"); } } + +/// Spec operator procedure for consistent config change: pause, apply, +/// resume — no transaction is in flight at the apply, so every transaction +/// planned after resume (WAL backlog included) routes whole under the new +/// config. A row planned before the pause under the old config stays +/// discarded; the while-paused backlog row reroutes and lands. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn pause_apply_resume_reroutes_backlog_whole() { + if !gated() { + return; + } + let mut h = Harness::up(&P4).await.expect("bring up harness"); + + let result = async { + // Unmapped table; id=1 plans (discards) before the pause. The users + // marker proves the reorder got past id=1's commit AND that the + // CREATE's boundary replayed into the shadow catalog (barrier order). + h.psql( + "CREATE TABLE demo.widgets (id bigint PRIMARY KEY, label text);\ + ALTER TABLE demo.widgets REPLICA IDENTITY FULL;", + )?; + h.psql("INSERT INTO demo.widgets VALUES (1, 'pre-config')")?; + h.psql("UPDATE demo.users SET email = 'marker1@x' WHERE id = 1")?; + h.wait_ch(USER_EMAIL, "marker1@x", Duration::from_secs(15)) + .await?; + assert_eq!( + h.ch_get("EXISTS TABLE demo.widgets")?, + "0", + "widgets must not exist on CH before opt-in", + ); + + h.ctl_body(&["apply"], "[stream]\npaused = true")?; + assert_eq!(h.status_field("paused")?, "true"); + // Wait past idle tick so the next write cannot race the pause + tokio::time::sleep(Duration::from_millis(600)).await; + + // Backlog row frozen in WAL, unplanned; then the opt-in applies + // while nothing is in flight. + h.psql("INSERT INTO demo.widgets VALUES (2, 'while-paused')")?; + h.ctl_body(&["apply"], "[table.demo.widgets]\nreplicate = true")?; + h.ctl(&["reload"])?; + + h.ctl_body(&["apply"], "[stream]\npaused = false")?; + assert_eq!(h.status_field("paused")?, "false"); + + // Backlog xact plans after resume → routes under the new config. + h.wait_ch( + "SELECT argMax(label, _lsn) FROM demo.widgets WHERE _is_deleted = 0 AND id = 2", + "while-paused", + Duration::from_secs(30), + ) + .await + .context("while-paused backlog row did not reroute after resume")?; + + // Post-resume stream continues under the same config. + h.psql("INSERT INTO demo.widgets VALUES (3, 'post-resume')")?; + h.wait_ch( + "SELECT argMax(label, _lsn) FROM demo.widgets WHERE _is_deleted = 0 AND id = 3", + "post-resume", + Duration::from_secs(15), + ) + .await?; + + // id=1 was planned pre-pause under the old config: discarded stays + // discarded, no partial re-plan. + let n = h.ch_get("SELECT count() FROM demo.widgets FINAL WHERE _is_deleted = 0")?; + assert_eq!(n, "2", "exactly the backlog + post-resume rows land"); + + assert!(h.alive(), "daemon exited during pause/apply/resume"); + Ok::<(), anyhow::Error>(()) + } + .await; + + let stderr = h.teardown(); + if let Err(e) = result { + panic!("{e:#}\n--- daemon stderr ---\n{stderr}"); + } +} diff --git a/tests/desc_log_e2e.rs b/tests/desc_log_e2e.rs new file mode 100644 index 00000000..b4a30289 --- /dev/null +++ b/tests/desc_log_e2e.rs @@ -0,0 +1,440 @@ +//! Descriptor-log capture end-to-end: prepared-xact DDL drains at COMMIT +//! PREPARED under the prepared xid, and capture-all (schema rename) keeps +//! decode routing on fresh namespace text. + +#![cfg(target_os = "linux")] + +#[path = "common/inproc_harness.rs"] +mod fx; + +use fx::spawn_txn; +use std::time::Duration; + +use walshadow::mapping::{ColumnMapping, TableTarget}; +use walshadow::schema::RelName; + +const SLOT_PREPARED: PortSlot = PortSlot { + source: 17960, + shadow: 17961, + ch_tcp: 17962, + ch_http: 17963, + walsender: 17967, +}; +const SLOT_RENAME: PortSlot = PortSlot { + source: 17970, + shadow: 17971, + ch_tcp: 17972, + ch_http: 17973, + walsender: 17977, +}; +const SLOT_INTERVAL: PortSlot = PortSlot { + source: 18020, + shadow: 18021, + ch_tcp: 18022, + ch_http: 18023, + walsender: 18027, +}; + +struct PortSlot { + source: u16, + shadow: u16, + ch_tcp: u16, + ch_http: u16, + walsender: u16, +} + +/// Live 2PC: DDL inside a prepared xact reaches CH at COMMIT PREPARED. +/// The commit record's header xid is the finishing backend's; the +/// capture-keyed events live under the prepared xid (B2) — pre-fix the +/// drain keyed header xid and stranded them. Rows written AFTER the ALTER +/// in the same xact are catalog-dirty admissions: they enter raw spill and +/// fence at commit (counted Skip) until raw decode replaces the fence — +/// pre-fence they decoded inline with the pre-ALTER descriptor, silently +/// dropping the new column's values. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn prepared_ddl_drains_at_commit_prepared() { + if !fx::pg_available() || !fx::pg_basebackup_available() || !fx::clickhouse_available() { + eprintln!("skip: missing initdb / pg_basebackup / clickhouse on PATH"); + return; + } + let slot = SLOT_PREPARED; + let tmp = tempfile::tempdir().unwrap(); + let ( + fx::BootstrappedClusters { + source, + shadow, + shadow_filter_dir, + }, + shadow_stream_state, + ) = fx::bootstrap_clusters( + &tmp, + "CREATE SCHEMA tp;\n\ + CREATE TABLE tp.twophase (id bigint PRIMARY KEY, v text);\n", + 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( + "tp".into(), + walshadow::mapping::NamespaceMapping { + target_database: Some("walshadow_test".into()), + auto_create: true, + drop_table_strategy: None, + }, + ); + + let mut 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: "walshadow-desc-prepared", + ddl: Some(ddl_args), + }) + .await; + + let driver = spawn_txn( + &source, + "BEGIN;\n\ + ALTER TABLE tp.twophase ADD COLUMN extra text;\n\ + INSERT INTO tp.twophase (id, v, extra) \ + SELECT g, 'x' || g, 'e' || g FROM generate_series(1, 8) g;\n\ + PREPARE TRANSACTION 'desc_log_2pc';\n\ + SELECT pg_switch_wal();\n\ + COMMIT PREPARED 'desc_log_2pc';\n\ + SELECT pg_switch_wal();\n", + ); + let shipped = fx::pump_segments(&mut pipeline, 2, Duration::from_secs(60)).await; + let _ = driver.join(); + assert!(shipped >= 2, "expected ≥2 shipped segments, got {shipped}"); + + let target = pipeline.stream.dispatched_lsn(); + let observed = shadow + .wait_for_replay(target, Duration::from_secs(30)) + .expect("shadow replay"); + assert!(observed >= target); + let stats = pipeline.stats.clone(); + pipeline.shutdown().await.expect("pipeline drains clean"); + let _ = shadow.stop(); + let _ = source.stop(); + + // INSERT...SELECT may land as one MULTI_INSERT record; raw decode fans + // every row out at commit resolution + assert_eq!( + stats.raw_decode_rows_ops.load().iter().sum::(), + 8, + "post-ALTER rows decode from the raw stash at commit" + ); + fx::wait_query( + &ch, + "SELECT count() FROM walshadow_test.twophase", + "8", + "catalog-dirty rows deliver via raw decode", + ) + .await; + fx::wait_query( + &ch, + "SELECT count() FROM system.columns \ + WHERE database = 'walshadow_test' AND table = 'twophase' AND name = 'extra'", + "1", + "prepared ALTER's Changed applies at COMMIT PREPARED", + ) + .await; +} + +/// Capture-all freshness: pg_namespace writes carry no per-relation relcache +/// invals, so a schema rename must recapture every descriptor — rows written +/// after the rename decode with the NEW namespace and route through a +/// mapping keyed under it. With stale descriptors they skip as unmapped. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn schema_rename_reroutes_under_new_namespace() { + if !fx::pg_available() || !fx::pg_basebackup_available() || !fx::clickhouse_available() { + eprintln!("skip: missing initdb / pg_basebackup / clickhouse on PATH"); + return; + } + let slot = SLOT_RENAME; + let tmp = tempfile::tempdir().unwrap(); + let ( + fx::BootstrappedClusters { + source, + shadow, + shadow_filter_dir, + }, + shadow_stream_state, + ) = fx::bootstrap_clusters(&tmp, "", 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"); + ch.query( + "CREATE OR REPLACE TABLE walshadow_test.renamed_t (\ + id Int64,\ + v Nullable(String),\ + _lsn UInt64,\ + _xid UInt32,\ + _commit_ts DateTime64(6, 'UTC'), _is_deleted Bool\ + ) ENGINE = ReplacingMergeTree(_lsn, _is_deleted) ORDER BY (id)", + ) + .expect("create dest table"); + + // Mapping pinned under the POST-rename name only + let mappings = vec![fx::TableMappingSpec { + source_table: RelName::new("n2", "t"), + target_table: TableTarget::new("walshadow_test", "renamed_t"), + columns: vec![ + ColumnMapping { + src_attnum: 1, + target_name: "id".into(), + target_type: "Int64".into(), + }, + ColumnMapping { + src_attnum: 2, + target_name: "v".into(), + target_type: "Nullable(String)".into(), + }, + ], + }]; + + let mut 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, + app_name: "walshadow-desc-rename", + ddl: Some(fx::DdlPipelineArgs::default()), + }) + .await; + + let driver = spawn_txn( + &source, + "CREATE SCHEMA n1;\n\ + CREATE TABLE n1.t (id bigint PRIMARY KEY, v text);\n\ + INSERT INTO n1.t (id, v) VALUES (1, 'pre');\n\ + ALTER SCHEMA n1 RENAME TO n2;\n\ + INSERT INTO n2.t (id, v) SELECT g, 'post' FROM generate_series(10, 14) g;\n\ + SELECT pg_switch_wal();\n", + ); + let shipped = fx::pump_segments(&mut pipeline, 1, Duration::from_secs(60)).await; + let _ = driver.join(); + assert!(shipped >= 1, "expected ≥1 shipped segment, got {shipped}"); + + let target = pipeline.stream.dispatched_lsn(); + let observed = shadow + .wait_for_replay(target, Duration::from_secs(30)) + .expect("shadow replay"); + assert!(observed >= target); + pipeline.shutdown().await.expect("pipeline drains clean"); + let _ = shadow.stop(); + let _ = source.stop(); + + // Post-rename rows decode under n2 and route; the pre-rename row's + // descriptor said n1 (unmapped) — skipped by design + fx::wait_query( + &ch, + "SELECT count() FROM walshadow_test.renamed_t WHERE v = 'post'", + "5", + "post-rename rows route under the new namespace", + ) + .await; + fx::wait_query( + &ch, + "SELECT count() FROM walshadow_test.renamed_t", + "5", + "pre-rename row stays unrouted (n1 unmapped)", + ) + .await; +} + +/// Phase-1 interval semantics against real captured history: a compatible +/// in-place change (column rename) answers `Present` across its dirty +/// interval; an unproven one (varchar widen = typmod change, no rewrite so +/// same rfn) answers `Ambiguous` over `[first_touch, next_lsn)` with the +/// predecessor before the interval and the final version at its end. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn in_place_intervals_compatible_and_ambiguous() { + use walrus::pg::walparser::RelFileNode; + use walshadow::desc_log::{AmbiguityReason, LookupResult}; + + if !fx::pg_available() || !fx::pg_basebackup_available() || !fx::clickhouse_available() { + eprintln!("skip: missing initdb / pg_basebackup / clickhouse on PATH"); + return; + } + let slot = SLOT_INTERVAL; + let tmp = tempfile::tempdir().unwrap(); + let ( + fx::BootstrappedClusters { + source, + shadow, + shadow_filter_dir, + }, + shadow_stream_state, + ) = fx::bootstrap_clusters( + &tmp, + "CREATE SCHEMA iv;\n\ + CREATE TABLE iv.t_ren (id bigint PRIMARY KEY, v text);\n\ + CREATE TABLE iv.t_wid (id bigint PRIMARY KEY, v varchar(10));\n\ + CREATE TABLE iv.t_typ (id bigint PRIMARY KEY, v varchar(10));\n", + slot.source, + slot.shadow, + slot.walsender, + ) + .await; + + let db_oid: u32 = source + .psql_one("SELECT oid FROM pg_database WHERE datname = current_database()") + .expect("db oid") + .parse() + .unwrap(); + let rfn_of = |rel: &str| -> (u32, RelFileNode) { + let row = source + .psql_one(&format!( + "SELECT c.oid::text || ' ' || c.relfilenode::text \ + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE n.nspname = 'iv' AND c.relname = '{rel}'" + )) + .expect("rel identity"); + let mut it = row.split_whitespace(); + let oid: u32 = it.next().unwrap().parse().unwrap(); + let rel_node: u32 = it.next().unwrap().parse().unwrap(); + ( + oid, + RelFileNode { + spc_node: 1663, // pg_default, reltablespace 0 resolved at capture + db_node: db_oid, + rel_node, + }, + ) + }; + let (oid_ren, rfn_ren) = rfn_of("t_ren"); + let (_oid_wid, rfn_wid) = rfn_of("t_wid"); + let (oid_typ, rfn_typ) = rfn_of("t_typ"); + + 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 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: "walshadow-desc-interval", + ddl: Some(fx::DdlPipelineArgs::default()), + }) + .await; + + let driver = spawn_txn( + &source, + "BEGIN;\n\ + INSERT INTO iv.t_ren (id, v) VALUES (1, 'a');\n\ + ALTER TABLE iv.t_ren RENAME COLUMN v TO w;\n\ + COMMIT;\n\ + BEGIN;\n\ + INSERT INTO iv.t_wid (id, v) VALUES (1, 'aaaa');\n\ + ALTER TABLE iv.t_wid ALTER COLUMN v TYPE varchar(20);\n\ + COMMIT;\n\ + ALTER TABLE iv.t_typ ALTER COLUMN v TYPE text;\n\ + SELECT pg_switch_wal();\n", + ); + let shipped = fx::pump_segments(&mut pipeline, 1, Duration::from_secs(60)).await; + let _ = driver.join(); + assert!(shipped >= 1, "expected ≥1 shipped segment, got {shipped}"); + + let target = pipeline.stream.dispatched_lsn(); + let observed = shadow + .wait_for_replay(target, Duration::from_secs(30)) + .expect("shadow replay"); + assert!(observed >= target); + let log = pipeline.desc_log.clone(); + pipeline.shutdown().await.expect("pipeline drains clean"); + let _ = shadow.stop(); + let _ = source.stop(); + + // Widen is benign: typmod never reaches the reader, so bias-early holds + // and no fence is published. varchar(20) typmod = 24 + assert!( + log.rfn_ambiguities(rfn_wid).is_empty(), + "typmod widen reads identically: {:?}", + log.rfn_ambiguities(rfn_wid), + ); + match log.descriptor_at(rfn_wid, log.head()) { + LookupResult::Present(d) => assert_eq!(d.attributes[1].typmod, 24), + other => panic!("expected widened Present, got {other:?}"), + } + + // varchar(10) -> text is binary coercible, so PG changes atttypid in + // place: no descriptor reads both, one interval per identity key + let ambs = log.rfn_ambiguities(rfn_typ); + assert_eq!(ambs.len(), 1, "type change publishes one rfn interval"); + let siblings = log.oid_ambiguities(oid_typ); + assert_eq!(siblings.len(), 1, "and its oid-keyed sibling"); + let amb = &ambs[0]; + assert_eq!(amb.reason, AmbiguityReason::UnknownMutationPosition); + assert_eq!( + (siblings[0].from_lsn, siblings[0].through_lsn), + (amb.from_lsn, amb.through_lsn), + "siblings name the same span", + ); + assert!(amb.from_lsn < amb.through_lsn); + for lsn in [amb.from_lsn, amb.through_lsn - 1] { + assert!(matches!( + log.descriptor_at(rfn_typ, lsn), + LookupResult::Ambiguous(_) + )); + assert!(matches!( + log.descriptor_by_oid_at(oid_typ, lsn), + LookupResult::Ambiguous(_) + )); + } + // Records stashed inside the span fence per record at the drain; the + // interval overlaps any range touching it + assert_eq!( + log.ambiguities_intersecting(rfn_typ, Some(oid_typ), amb.from_lsn, amb.through_lsn) + .len(), + 2, + "both keys answer the drain's fence query", + ); + match log.descriptor_at(rfn_typ, amb.through_lsn) { + // text + LookupResult::Present(d) => assert_eq!(d.attributes[1].type_oid, 25), + other => panic!("expected final Present at through_lsn, got {other:?}"), + } + let pred = log + .present_before(rfn_typ, amb.from_lsn) + .expect("durable predecessor"); + assert_eq!(pred.attributes[1].type_oid, 1043, "varchar predecessor"); + + // Compatible rename: no ambiguity, bias-early Present answers across + // the interval with the final attribute name + assert!(log.rfn_ambiguities(rfn_ren).is_empty()); + match log.descriptor_by_oid_at(oid_ren, u64::MAX) { + LookupResult::Present(d) => assert_eq!(d.attributes[1].name, "w"), + other => panic!("expected renamed Present, got {other:?}"), + } + match log.descriptor_at(rfn_ren, log.head()) { + LookupResult::Present(d) => assert_eq!(d.attributes[1].name, "w"), + other => panic!("expected Present across rename interval, got {other:?}"), + } +} diff --git a/tests/desc_log_restart_e2e.rs b/tests/desc_log_restart_e2e.rs new file mode 100644 index 00000000..ffd8d475 --- /dev/null +++ b/tests/desc_log_restart_e2e.rs @@ -0,0 +1,343 @@ +//! Restart-safety of catalog-boundary classification: a namespace rename +//! whose pg_namespace WAL (and command-end inval record) precede the +//! restart resume floor must still classify at its commit record — the +//! commit carries the xact tree's full inval set, and pg_namespace +//! catcache invals force capture-all — so rows written after the commit +//! route under the new namespace. Covered for plain COMMIT across a held +//! session and for PREPARE TRANSACTION / COMMIT PREPARED. + +#![cfg(target_os = "linux")] + +#[path = "common/inproc_harness.rs"] +mod fx; + +use fx::spawn_txn; +use std::io::Write as _; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use walshadow::mapping::{ColumnMapping, TableTarget}; +use walshadow::schema::RelName; +use walshadow::shadow::Shadow; + +const SLOT_COMMIT: PortSlot = PortSlot { + source: 17980, + shadow: 17981, + ch_tcp: 17982, + ch_http: 17983, + walsender: 17987, +}; +const SLOT_PREPARED: PortSlot = PortSlot { + source: 17990, + shadow: 17991, + ch_tcp: 17992, + ch_http: 17993, + walsender: 17997, +}; + +struct PortSlot { + source: u16, + shadow: u16, + ch_tcp: u16, + ch_http: u16, + walsender: u16, +} + +/// psql session holding a transaction open across a pipeline restart; +/// statements execute as lines arrive on stdin +struct TxnSession { + child: std::process::Child, +} + +impl TxnSession { + fn open(source: &Shadow) -> Self { + let child = Command::new("psql") + .args([ + "-h", + source.config().socket_dir.to_str().unwrap(), + "-p", + &source.config().port.to_string(), + "-U", + "postgres", + "-d", + "postgres", + "-v", + "ON_ERROR_STOP=1", + "-f", + "-", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn psql"); + Self { child } + } + + fn send(&mut self, sql: &str) { + self.child + .stdin + .as_mut() + .expect("stdin piped") + .write_all(sql.as_bytes()) + .expect("write to held psql"); + } + + fn finish(mut self) { + drop(self.child.stdin.take()); + let status = self.child.wait().expect("wait psql"); + assert!(status.success(), "held psql session failed: {status}"); + } +} + +/// Poll until the held session's statements executed (session parked idle +/// in transaction) so its WAL precedes the upcoming segment switch +fn wait_idle_in_txn(source: &Shadow) { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let n = source + .psql_one("SELECT count(*) FROM pg_stat_activity WHERE state = 'idle in transaction'") + .expect("poll pg_stat_activity"); + if n.trim() == "1" { + return; + } + assert!( + std::time::Instant::now() < deadline, + "open txn never appeared" + ); + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn create_dest_table(ch: &fx::ChServer, table: &str) { + ch.query("CREATE DATABASE IF NOT EXISTS walshadow_test") + .expect("create db"); + ch.query(&format!( + "CREATE OR REPLACE TABLE walshadow_test.{table} (\ + id Int64,\ + v Nullable(String),\ + _lsn UInt64,\ + _xid UInt32,\ + _commit_ts DateTime64(6, 'UTC'), _is_deleted Bool\ + ) ENGINE = ReplacingMergeTree(_lsn, _is_deleted) ORDER BY (id)" + )) + .expect("create dest table"); +} + +fn mappings_for(namespace: &str, table: &str) -> Vec { + vec![fx::TableMappingSpec { + source_table: RelName::new(namespace, "t"), + target_table: TableTarget::new("walshadow_test", table), + columns: vec![ + ColumnMapping { + src_attnum: 1, + target_name: "id".into(), + target_type: "Int64".into(), + }, + ColumnMapping { + src_attnum: 2, + target_name: "v".into(), + target_type: "Nullable(String)".into(), + }, + ], + }] +} + +/// Held rename commits only after a restart. Segment 1 (pg_namespace WAL + +/// command-end inval record) ships and the pipeline stops; the rebuilt +/// pipeline resumes at the next segment — dirty state gone, catalog +/// records unseen — and must classify the commit from its inval set alone. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn rename_commit_after_restart_reroutes() { + if !fx::pg_available() || !fx::pg_basebackup_available() || !fx::clickhouse_available() { + eprintln!("skip: missing initdb / pg_basebackup / clickhouse on PATH"); + return; + } + let slot = SLOT_COMMIT; + let tmp = tempfile::tempdir().unwrap(); + let ( + fx::BootstrappedClusters { + source, + shadow, + shadow_filter_dir, + }, + shadow_stream_state, + ) = fx::bootstrap_clusters( + &tmp, + "CREATE SCHEMA rs1;\n\ + CREATE TABLE rs1.t (id bigint PRIMARY KEY, v text);\n", + 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"); + create_dest_table(&ch, "restart_t"); + + // Mapping pinned under the POST-rename name only + let mut pipeline = fx::build_pipeline(fx::BuildPipelineArgs { + tmp: &tmp, + source: &source, + shadow: &shadow, + shadow_filter_dir: &shadow_filter_dir, + shadow_stream_state: shadow_stream_state.clone(), + ch_database: "walshadow_test", + ch_tcp_port: slot.ch_tcp, + mappings: mappings_for("rs2", "restart_t"), + app_name: "walshadow-desc-restart", + ddl: Some(fx::DdlPipelineArgs::default()), + }) + .await; + + let mut txn = TxnSession::open(&source); + txn.send("BEGIN;\nALTER SCHEMA rs1 RENAME TO rs2;\n"); + wait_idle_in_txn(&source); + source.psql_one("SELECT pg_switch_wal()").expect("rotate"); + let shipped = fx::pump_segments(&mut pipeline, 1, Duration::from_secs(60)).await; + assert!(shipped >= 1, "expected ≥1 shipped segment, got {shipped}"); + pipeline.shutdown().await.expect("pipeline drains clean"); + + // Restart: rebuilt stream resumes at the current segment head, past the + // rename's catalog WAL; volatile catalog-dirty state is gone + let mut 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: mappings_for("rs2", "restart_t"), + app_name: "walshadow-desc-restart-2", + ddl: Some(fx::DdlPipelineArgs::default()), + }) + .await; + + txn.send( + "COMMIT;\n\ + INSERT INTO rs2.t (id, v) SELECT g, 'post' FROM generate_series(1, 5) g;\n\ + SELECT pg_switch_wal();\n", + ); + txn.finish(); + let shipped = fx::pump_segments(&mut pipeline, 1, Duration::from_secs(60)).await; + assert!(shipped >= 1, "expected ≥1 shipped segment, got {shipped}"); + + let target = pipeline.stream.dispatched_lsn(); + let observed = shadow + .wait_for_replay(target, Duration::from_secs(30)) + .expect("shadow replay"); + assert!(observed >= target); + pipeline.shutdown().await.expect("pipeline drains clean"); + let _ = shadow.stop(); + let _ = source.stop(); + + fx::wait_query( + &ch, + "SELECT count() FROM walshadow_test.restart_t WHERE v = 'post'", + "5", + "post-restart commit recaptures namespace, rows route under rs2", + ) + .await; +} + +/// Same classification hole through 2PC: PREPARE lands before the restart, +/// COMMIT PREPARED after. The commit-prepared record carries the prepared +/// xact's inval set; namespace catcache invals must force capture-all. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn prepared_rename_commit_after_restart_reroutes() { + if !fx::pg_available() || !fx::pg_basebackup_available() || !fx::clickhouse_available() { + eprintln!("skip: missing initdb / pg_basebackup / clickhouse on PATH"); + return; + } + let slot = SLOT_PREPARED; + let tmp = tempfile::tempdir().unwrap(); + let ( + fx::BootstrappedClusters { + source, + shadow, + shadow_filter_dir, + }, + shadow_stream_state, + ) = fx::bootstrap_clusters( + &tmp, + "CREATE SCHEMA ps1;\n\ + CREATE TABLE ps1.t (id bigint PRIMARY KEY, v text);\n", + 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"); + create_dest_table(&ch, "restart_2pc_t"); + + let mut pipeline = fx::build_pipeline(fx::BuildPipelineArgs { + tmp: &tmp, + source: &source, + shadow: &shadow, + shadow_filter_dir: &shadow_filter_dir, + shadow_stream_state: shadow_stream_state.clone(), + ch_database: "walshadow_test", + ch_tcp_port: slot.ch_tcp, + mappings: mappings_for("ps2", "restart_2pc_t"), + app_name: "walshadow-desc-restart-2pc", + ddl: Some(fx::DdlPipelineArgs::default()), + }) + .await; + + let driver = spawn_txn( + &source, + "BEGIN;\n\ + ALTER SCHEMA ps1 RENAME TO ps2;\n\ + PREPARE TRANSACTION 'ns_rename_2pc';\n\ + SELECT pg_switch_wal();\n", + ); + let shipped = fx::pump_segments(&mut pipeline, 1, Duration::from_secs(60)).await; + let _ = driver.join(); + assert!(shipped >= 1, "expected ≥1 shipped segment, got {shipped}"); + pipeline.shutdown().await.expect("pipeline drains clean"); + + let mut 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: mappings_for("ps2", "restart_2pc_t"), + app_name: "walshadow-desc-restart-2pc-2", + ddl: Some(fx::DdlPipelineArgs::default()), + }) + .await; + + let driver = spawn_txn( + &source, + "COMMIT PREPARED 'ns_rename_2pc';\n\ + INSERT INTO ps2.t (id, v) SELECT g, 'post' FROM generate_series(1, 5) g;\n\ + SELECT pg_switch_wal();\n", + ); + let shipped = fx::pump_segments(&mut pipeline, 1, Duration::from_secs(60)).await; + let _ = driver.join(); + assert!(shipped >= 1, "expected ≥1 shipped segment, got {shipped}"); + + let target = pipeline.stream.dispatched_lsn(); + let observed = shadow + .wait_for_replay(target, Duration::from_secs(30)) + .expect("shadow replay"); + assert!(observed >= target); + pipeline.shutdown().await.expect("pipeline drains clean"); + let _ = shadow.stop(); + let _ = source.stop(); + + fx::wait_query( + &ch, + "SELECT count() FROM walshadow_test.restart_2pc_t WHERE v = 'post'", + "5", + "COMMIT PREPARED after restart recaptures namespace, rows route under ps2", + ) + .await; +} diff --git a/tests/dirty_admission_e2e.rs b/tests/dirty_admission_e2e.rs new file mode 100644 index 00000000..f17eed22 --- /dev/null +++ b/tests/dirty_admission_e2e.rs @@ -0,0 +1,648 @@ +//! Dirty-admission end-to-end against real WAL: catalog touch inside a +//! transaction tree defers that tree's later rows (raw spill → decode at +//! commit resolution) without leaking onto interleaved clean +//! transactions; subxact touch defers top and child records; top-level +//! abort with DDL appends no descriptor metadata and emits nothing. +//! +//! Deferred rows decode against the commit-time descriptor and deliver — +//! the `BEGIN; DDL; DML; COMMIT` row-loss class raw decode exists to +//! kill. + +#![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_INTERLEAVE: PortSlot = PortSlot { + source: 18030, + shadow: 18031, + ch_tcp: 18032, + ch_http: 18033, + walsender: 18037, +}; +const SLOT_SUBXACT: PortSlot = PortSlot { + source: 18040, + shadow: 18041, + ch_tcp: 18042, + ch_http: 18043, + walsender: 18047, +}; +const SLOT_TOP_ABORT: PortSlot = PortSlot { + source: 18050, + shadow: 18051, + ch_tcp: 18052, + ch_http: 18053, + walsender: 18057, +}; +const SLOT_GATE: PortSlot = PortSlot { + source: 18060, + shadow: 18061, + ch_tcp: 18062, + ch_http: 18063, + walsender: 18067, +}; +const SLOT_CREATE_COPY: PortSlot = PortSlot { + source: 18070, + shadow: 18071, + ch_tcp: 18072, + ch_http: 18073, + walsender: 18077, +}; +const SLOT_BENIGN: PortSlot = PortSlot { + source: 18080, + shadow: 18081, + ch_tcp: 18082, + ch_http: 18083, + walsender: 18087, +}; +const SLOT_FENCE: PortSlot = PortSlot { + source: 18090, + shadow: 18091, + ch_tcp: 18092, + ch_http: 18093, + walsender: 18097, +}; + +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, +} + +/// Bootstrap clusters + CH + auto-create pipeline for one namespace. +async fn build_drill(slot: PortSlot, schema_sql: &str, namespace: &str, app_name: &str) -> Drill { + let tmp = tempfile::tempdir().unwrap(); + let ( + fx::BootstrappedClusters { + source, + shadow, + shadow_filter_dir, + }, + shadow_stream_state, + ) = fx::bootstrap_clusters(&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( + namespace.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, + } +} + +/// Pump one switched segment, wait shadow replay, drain pipeline. +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); +} + +/// Clean transaction commits BETWEEN a prepared dirty xact's catalog touch +/// and its COMMIT PREPARED — single connection, so WAL interleave is +/// deterministic. Dirty state must fence only the prepared tree: clean +/// rows deliver, dirty post-touch row fences. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn interleaved_clean_xact_unaffected_by_dirty_tree() { + if skip_gate() { + return; + } + let mut drill = build_drill( + SLOT_INTERLEAVE, + "CREATE SCHEMA dai;\n\ + CREATE TABLE dai.dirty_t (id bigint PRIMARY KEY, v text);\n\ + CREATE TABLE dai.clean_t (id bigint PRIMARY KEY, v text);\n", + "dai", + "walshadow-dirty-interleave", + ) + .await; + + let driver = spawn_txn( + &drill.source, + "BEGIN;\n\ + ALTER TABLE dai.dirty_t ADD COLUMN extra text;\n\ + INSERT INTO dai.dirty_t (id, v, extra) VALUES (1, 'd1', 'e1');\n\ + PREPARE TRANSACTION 'dirty_interleave';\n\ + INSERT INTO dai.clean_t (id, v) VALUES (1, 'c1'), (2, 'c2');\n\ + COMMIT PREPARED 'dirty_interleave';\n\ + SELECT pg_switch_wal();\n", + ); + pump_and_drain(&mut drill).await; + let _ = driver.join(); + + let decoder_stats = drill.pipeline.sinks.decoder.stats_handle(); + let emitter_stats = drill.pipeline.stats.clone(); + drill.pipeline.shutdown().await.expect("pipeline drains"); + let _ = drill.shadow.stop(); + let _ = drill.source.stop(); + let ch = &drill.ch; + + fx::wait_query( + ch, + "SELECT count() FROM walshadow_test.clean_t WHERE _is_deleted = 0", + "2", + "clean xact rows deliver despite concurrent dirty tree", + ) + .await; + fx::wait_query( + ch, + "SELECT count() FROM system.columns \ + WHERE database = 'walshadow_test' AND table = 'dirty_t' AND name = 'extra'", + "1", + "prepared ALTER applies at COMMIT PREPARED", + ) + .await; + fx::wait_query( + ch, + "SELECT concat(v, '/', extra) FROM walshadow_test.dirty_t \ + WHERE id = 1 AND _is_deleted = 0", + "d1/e1", + "dirty post-touch row decodes raw and delivers with the added column", + ) + .await; + assert!( + decoder_stats.raw_stash_deferred.load(Ordering::Relaxed) >= 1, + "dirty row enters raw spill", + ); + assert!( + emitter_stats.raw_decode_rows_ops.load().iter().sum::() >= 1, + "deferred row decodes at commit resolution", + ); + assert!( + emitter_stats.plan_rows.load(Ordering::Relaxed) >= 3, + "clean and raw-decoded rows sealed into plans", + ); + assert!( + emitter_stats.plan_bytes_mem.load(Ordering::Relaxed) > 0, + "small plans stay memory-resident", + ); + assert!( + emitter_stats.route_snapshots_mapped.load(Ordering::Relaxed) >= 1, + "plan-time route resolution counted", + ); +} + +/// Catalog touch inside a subxact dirties the whole known tree: child row +/// after the touch AND top-level row after RELEASE both defer; top row +/// written BEFORE the touch decodes with the predecessor descriptor and +/// delivers. Subxact's DDL observation survives RELEASE into the top +/// commit's capture boundary. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn subxact_catalog_touch_defers_top_and_child_rows() { + if skip_gate() { + return; + } + let mut drill = build_drill( + SLOT_SUBXACT, + "CREATE SCHEMA das;\n\ + CREATE TABLE das.t (id bigint PRIMARY KEY, v text);\n", + "das", + "walshadow-dirty-subxact", + ) + .await; + + let driver = spawn_txn( + &drill.source, + "BEGIN;\n\ + INSERT INTO das.t (id, v) VALUES (1, 'pre');\n\ + SAVEPOINT sp;\n\ + ALTER TABLE das.t ADD COLUMN extra text;\n\ + INSERT INTO das.t (id, v, extra) VALUES (2, 'child', 'e2');\n\ + RELEASE SAVEPOINT sp;\n\ + INSERT INTO das.t (id, v) VALUES (3, 'top-post');\n\ + COMMIT;\n\ + SELECT pg_switch_wal();\n", + ); + pump_and_drain(&mut drill).await; + let _ = driver.join(); + + let decoder_stats = drill.pipeline.sinks.decoder.stats_handle(); + let emitter_stats = drill.pipeline.stats.clone(); + drill.pipeline.shutdown().await.expect("pipeline drains"); + let _ = drill.shadow.stop(); + let _ = drill.source.stop(); + let ch = &drill.ch; + + fx::wait_query( + ch, + "SELECT count() FROM system.columns \ + WHERE database = 'walshadow_test' AND table = 't' AND name = 'extra'", + "1", + "subxact ALTER survives RELEASE into top commit boundary", + ) + .await; + fx::wait_query( + 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=child,3=top-post", + "pre-touch row decodes inline; child + post-RELEASE rows decode raw", + ) + .await; + assert!( + decoder_stats.raw_stash_deferred.load(Ordering::Relaxed) >= 2, + "child row and post-RELEASE top row both enter raw spill", + ); + assert!( + emitter_stats.raw_decode_rows_ops.load().iter().sum::() >= 2, + "both deferred rows decode at commit resolution", + ); +} + +/// EVERY post-touch ordinary record enters raw spill and decodes at +/// commit — exact record accounting across single INSERT (1 record), +/// multi-VALUES INSERT (per-row heap_insert, 2), COPY (one Heap2 +/// MULTI_INSERT), UPDATE (1), DELETE (1) = 6 records fanning out 8 rows. +/// Pre-touch row decodes inline via predecessor; the merged end state +/// reflects all of them. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn phase_gate_every_post_touch_record_defers_and_fences() { + if skip_gate() { + return; + } + let mut drill = build_drill( + SLOT_GATE, + "CREATE SCHEMA dag;\n\ + CREATE TABLE dag.t (id bigint PRIMARY KEY, v text);\n\ + ALTER TABLE dag.t REPLICA IDENTITY FULL;\n", + "dag", + "walshadow-dirty-gate", + ) + .await; + + let driver = spawn_txn( + &drill.source, + "BEGIN;\n\ + INSERT INTO dag.t (id, v) VALUES (1, 'pre');\n\ + ALTER TABLE dag.t ADD COLUMN extra text;\n\ + INSERT INTO dag.t (id, v, extra) VALUES (2, 'i2', 'e2');\n\ + INSERT INTO dag.t (id, v, extra) VALUES (3, 'i3', 'e3'), (4, 'i4', 'e4');\n\ + COPY dag.t (id, v, extra) FROM stdin;\n\ + 5\tc5\te5\n\ + 6\tc6\te6\n\ + 7\tc7\te7\n\ + \\.\n\ + UPDATE dag.t SET v = 'u2' WHERE id = 2;\n\ + DELETE FROM dag.t WHERE id = 3;\n\ + COMMIT;\n\ + SELECT pg_switch_wal();\n", + ); + pump_and_drain(&mut drill).await; + let _ = driver.join(); + + let decoder_stats = drill.pipeline.sinks.decoder.stats_handle(); + let emitter_stats = drill.pipeline.stats.clone(); + drill.pipeline.shutdown().await.expect("pipeline drains"); + let _ = drill.shadow.stop(); + let _ = drill.source.stop(); + let ch = &drill.ch; + + fx::wait_query( + ch, + "SELECT arrayStringConcat(groupArray(c), ',') FROM (\ + SELECT concat(toString(id), '=', argMax(v, _lsn)) AS c \ + FROM walshadow_test.t \ + GROUP BY id HAVING argMax(_is_deleted, _lsn) = 0 ORDER BY id)", + "1=pre,2=u2,4=i4,5=c5,6=c6,7=c7", + "raw-decoded inserts, update, and delete all reflect in the end state", + ) + .await; + fx::wait_query( + ch, + "SELECT count() FROM system.columns \ + WHERE database = 'walshadow_test' AND table = 't' AND name = 'extra'", + "1", + "ALTER applies at commit boundary", + ) + .await; + assert_eq!( + decoder_stats.raw_stash_deferred.load(Ordering::Relaxed), + 6, + "every post-touch ordinary record enters raw spill", + ); + assert_eq!( + emitter_stats + .raw_decode_ordinary_ops + .load() + .iter() + .sum::(), + 6, + "commit decodes each deferred record", + ); + assert_eq!( + emitter_stats.raw_decode_rows_ops.load().iter().sum::(), + 8, + "MULTI_INSERT fans out per row", + ); + assert_eq!( + decoder_stats.toast_stash_buffered.load(Ordering::Relaxed), + 0, + "no marker-path admissions; defer branch owns dirty records", + ); +} + +/// Primary regression target: rows COPYed into a table created in the +/// SAME transaction deliver to an auto-created CH table. Every record +/// after the CREATE stashes raw; commit resolution describes them with +/// the commit-time descriptor, auto-create Added applies before the +/// route snapshot, and the plan routes the fanned-out rows. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn create_table_and_copy_same_xact_delivers() { + if skip_gate() { + return; + } + let mut drill = build_drill( + SLOT_CREATE_COPY, + "CREATE SCHEMA dac;\n", + "dac", + "walshadow-dirty-create-copy", + ) + .await; + + let driver = spawn_txn( + &drill.source, + "BEGIN;\n\ + CREATE TABLE dac.fresh (id bigint PRIMARY KEY, v text);\n\ + COPY dac.fresh (id, v) FROM stdin;\n\ + 1\ta\n\ + 2\tb\n\ + 3\tc\n\ + \\.\n\ + COMMIT;\n\ + SELECT pg_switch_wal();\n", + ); + pump_and_drain(&mut drill).await; + let _ = driver.join(); + + let decoder_stats = drill.pipeline.sinks.decoder.stats_handle(); + let emitter_stats = drill.pipeline.stats.clone(); + drill.pipeline.shutdown().await.expect("pipeline drains"); + let _ = drill.shadow.stop(); + let _ = drill.source.stop(); + let ch = &drill.ch; + + fx::wait_query( + ch, + "SELECT arrayStringConcat(groupArray(c), ',') FROM (\ + SELECT concat(toString(id), '=', v) AS c \ + FROM walshadow_test.fresh WHERE _is_deleted = 0 ORDER BY id)", + "1=a,2=b,3=c", + "same-xact CREATE + COPY rows deliver via raw decode", + ) + .await; + assert!( + decoder_stats.raw_stash_deferred.load(Ordering::Relaxed) >= 1, + "COPY records enter raw spill", + ); + assert_eq!( + emitter_stats.raw_decode_rows_ops.load().iter().sum::(), + 3, + "COPY fans out three rows at commit resolution", + ); +} + +/// Read-identical in-place drift (typmod, attstorage) publishes no fence, +/// so post-ALTER rows in the same transaction still deliver. This is the +/// shape the benign/physical compat split protects: fencing it would make +/// `BEGIN; ALTER COLUMN TYPE varchar(n); INSERT; COMMIT;` permanently fatal. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn benign_in_place_alter_then_dml_delivers() { + if skip_gate() { + return; + } + let mut drill = build_drill( + SLOT_BENIGN, + "CREATE SCHEMA dab;\n\ + CREATE TABLE dab.t (id bigint PRIMARY KEY, v varchar(10));\n", + "dab", + "walshadow-dirty-benign", + ) + .await; + let capture_stats = drill + .pipeline + .sinks + .capture + .as_ref() + .expect("capture wired") + .stats_handle(); + + let driver = spawn_txn( + &drill.source, + "BEGIN;\n\ + ALTER TABLE dab.t ALTER COLUMN v TYPE varchar(40);\n\ + ALTER TABLE dab.t ALTER COLUMN v SET STORAGE EXTERNAL;\n\ + INSERT INTO dab.t (id, v) VALUES (1, 'post');\n\ + COMMIT;\n\ + SELECT pg_switch_wal();\n", + ); + pump_and_drain(&mut drill).await; + let _ = driver.join(); + + let decoder_stats = drill.pipeline.sinks.decoder.stats_handle(); + let log_stats = drill.pipeline.desc_log.stats_handle(); + drill.pipeline.shutdown().await.expect("pipeline drains"); + let _ = drill.shadow.stop(); + let _ = drill.source.stop(); + let ch = &drill.ch; + + fx::wait_query( + ch, + "SELECT argMax(v, _lsn) FROM walshadow_test.t WHERE id = 1", + "post", + "post-ALTER row decodes under the commit-time descriptor", + ) + .await; + assert!( + decoder_stats.raw_stash_deferred.load(Ordering::Relaxed) >= 1, + "post-touch row still defers", + ); + assert_eq!( + capture_stats.ambiguities_published.load(Ordering::Relaxed), + 0, + "typmod + storage drift publishes no fence", + ); + assert_eq!( + log_stats.lookups_ambiguous.load(Ordering::Relaxed), + 0, + "and no lookup lands in one", + ); +} + +/// A physically unproven in-place change (varchar -> text is binary +/// 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. +#[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( + SLOT_FENCE, + "CREATE SCHEMA daf;\n\ + CREATE TABLE daf.t (id bigint PRIMARY KEY, v varchar(10));\n", + "daf", + "walshadow-dirty-fence", + ) + .await; + + let driver = spawn_txn( + &drill.source, + "BEGIN;\n\ + ALTER TABLE daf.t ALTER COLUMN v TYPE text;\n\ + INSERT INTO daf.t (id, v) VALUES (1, 'fenced');\n\ + COMMIT;\n\ + SELECT pg_switch_wal();\n", + ); + let err = fx::pump_segments_res(&mut drill.pipeline, 1, Duration::from_secs(45)) + .await + .expect_err("fenced record must stop the stream"); + let _ = driver.join(); + // Close the replication connection before stopping the clusters: pg_ctl + // waits out its whole timeout on a live walsender + let _ = drill.pipeline.shutdown().await; + let _ = drill.shadow.stop(); + let _ = drill.source.stop(); + assert!( + err.contains("inside ambiguity"), + "expected the fence to name its interval, got {err}", + ); +} + +/// Top-level ROLLBACK of a DDL + row transaction: no descriptor batch +/// appends, no rows emit, no fence counts, and the next transaction on +/// the same connection inherits no dirty state. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn top_abort_with_ddl_appends_no_metadata_and_emits_no_rows() { + if skip_gate() { + return; + } + let mut drill = build_drill( + SLOT_TOP_ABORT, + "CREATE SCHEMA daa;\n\ + CREATE TABLE daa.t (id bigint PRIMARY KEY, v text);\n", + "daa", + "walshadow-dirty-top-abort", + ) + .await; + let log_stats = drill.pipeline.desc_log.stats_handle(); + let batches_before = log_stats.batches_appended.load(Ordering::Relaxed); + + let driver = spawn_txn( + &drill.source, + "INSERT INTO daa.t (id, v) VALUES (99, 'sentinel');\n\ + BEGIN;\n\ + ALTER TABLE daa.t ADD COLUMN extra text;\n\ + INSERT INTO daa.t (id, v, extra) VALUES (1, 'doomed', 'e1');\n\ + ROLLBACK;\n\ + INSERT INTO daa.t (id, v) VALUES (2, 'after');\n\ + SELECT pg_switch_wal();\n", + ); + pump_and_drain(&mut drill).await; + let _ = driver.join(); + + let decoder_stats = drill.pipeline.sinks.decoder.stats_handle(); + let emitter_stats = drill.pipeline.stats.clone(); + drill.pipeline.shutdown().await.expect("pipeline drains"); + let _ = drill.shadow.stop(); + let _ = drill.source.stop(); + let ch = &drill.ch; + + fx::wait_query( + 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)", + "2=after,99=sentinel", + "post-abort insert delivers; aborted xact's row does not", + ) + .await; + assert_eq!( + ch.query( + "SELECT count() FROM system.columns \ + WHERE database = 'walshadow_test' AND table = 't' AND name = 'extra'", + ) + .unwrap(), + "0", + "rolled-back ALTER must not reach CH", + ); + assert_eq!( + log_stats.batches_appended.load(Ordering::Relaxed), + batches_before, + "aborted DDL appends no descriptor metadata", + ); + assert!( + decoder_stats.raw_stash_deferred.load(Ordering::Relaxed) >= 1, + "doomed row deferred raw before abort", + ); + assert_eq!( + emitter_stats + .raw_decode_ordinary_ops + .load() + .iter() + .sum::(), + 0, + "abort discards raw entries without decoding", + ); +} diff --git a/tests/emitter_budget_flush.rs b/tests/emitter_budget_flush.rs index 6fdf00d7..ba13adc3 100644 --- a/tests/emitter_budget_flush.rs +++ b/tests/emitter_budget_flush.rs @@ -43,6 +43,7 @@ fn rel_descriptor() -> Arc { Arc::new(RelDescriptor { rfn: RFN, oid: 16385, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", "foo"), kind: 'r', @@ -162,7 +163,7 @@ async fn budget_trips_seal_complete_inserts() { .expect("spawn tail"); let rel = rel_descriptor(); - let mapping = mapping(); + let route = walshadow::emit::route::RouteSnapshot::freeze(mapping(), Arc::default(), false); const N: i32 = 5; let commit_lsn = 0xC0FFEE; ack.register(0, commit_lsn); @@ -171,7 +172,7 @@ async fn budget_trips_seal_complete_inserts() { .send(BatcherMsg::Row(RoutedRow { seq: 0, rel: rel.clone(), - mapping: mapping.clone(), + route: route.clone(), committed: tuple(i, 0x1000 + i as u64, commit_lsn), permit: None, value_permit: None, diff --git a/tests/emitter_native_types.rs b/tests/emitter_native_types.rs index 7166b3df..79f6b8c9 100644 --- a/tests/emitter_native_types.rs +++ b/tests/emitter_native_types.rs @@ -46,6 +46,7 @@ fn rel_descriptor() -> RelDescriptor { RelDescriptor { rfn: RFN, oid: 16385, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", "things"), kind: 'r', @@ -164,7 +165,7 @@ async fn native_numeric_time_timetz_round_trip() { .send(BatcherMsg::Row(RoutedRow { seq: 0, rel, - mapping, + route: walshadow::emit::route::RouteSnapshot::freeze(mapping, Arc::default(), false), committed: tuple, permit: None, value_permit: None, diff --git a/tests/emitter_tls.rs b/tests/emitter_tls.rs index d16a9ad3..42633879 100644 --- a/tests/emitter_tls.rs +++ b/tests/emitter_tls.rs @@ -297,6 +297,7 @@ fn rel_descriptor() -> RelDescriptor { RelDescriptor { rfn: RFN, oid: 16385, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", "things"), kind: 'r', @@ -390,7 +391,7 @@ async fn emitter_tls_round_trip() { .send(BatcherMsg::Row(RoutedRow { seq: 0, rel, - mapping, + route: walshadow::emit::route::RouteSnapshot::freeze(mapping, Arc::default(), false), committed: tuple, permit: None, value_permit: None, diff --git a/tests/multi_segment_filter.rs b/tests/multi_segment_filter.rs index 211c0e92..637245d2 100644 --- a/tests/multi_segment_filter.rs +++ b/tests/multi_segment_filter.rs @@ -322,9 +322,12 @@ impl RecordSink for SharedCollectingSink { self.0.lock().unwrap().push(Record { parsed: r.parsed.clone().into_owned(), source_lsn: r.source_lsn, + next_lsn: r.next_lsn, page_magic: r.page_magic, route: r.route, - catalog_signal: r.catalog_signal, + catalog_boundary: r.catalog_boundary, + boundary_info: r.boundary_info.clone(), + defer_catalog_decode: r.defer_catalog_decode, }); Ok(()) }) diff --git a/tests/oracle.rs b/tests/oracle.rs index 5af0cab4..3eae35bb 100644 --- a/tests/oracle.rs +++ b/tests/oracle.rs @@ -125,7 +125,7 @@ async fn oracle_without_extension_falls_back_to_raw_bytes() { "postgres", "postgres", ); - let oracle = Oracle::connect(&conninfo, 0).await.expect("oracle connect"); + 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 @@ -171,7 +171,7 @@ async fn oracle_with_extension_resolves_tier3_disk_bytes() { "postgres", "postgres", ); - let oracle = Oracle::connect(&conninfo, 0).await.expect("oracle connect"); + let oracle = Oracle::connect(&conninfo).await.expect("oracle connect"); assert!(oracle.has_extension()); // numeric — 42 @@ -239,7 +239,7 @@ async fn oracle_resolves_pg_pending_to_text() { "postgres", "postgres", ); - let oracle = Arc::new(Oracle::connect(&conninfo, 0).await.expect("oracle connect")); + let oracle = Arc::new(Oracle::connect(&conninfo).await.expect("oracle connect")); // Wire one PgPending column (numeric 42) through the decode pool's // resolution path. @@ -278,46 +278,6 @@ async fn oracle_resolves_pg_pending_to_text() { assert_eq!(oracle.stats.resolved.load(Ordering::Relaxed), 1); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn oracle_validate_counts_match_and_mismatch() { - if !pg_available() { - eprintln!("skip: no initdb on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let sh = make_pg(&tmp, SHADOW_PORT + 3); - 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, 1).await.expect("oracle connect"); - - use std::sync::atomic::Ordering; - use walshadow::schema::{INT4OID, NUMERICOID}; - assert!(oracle.validate(NUMERICOID, &numeric_42_bytes(), "42").await); - assert!( - oracle - .validate(NUMERICOID, &numeric_42_bytes(), "not-42") - .await - ); - assert_eq!(oracle.stats.probes.load(Ordering::Relaxed), 2); - assert_eq!(oracle.stats.matches.load(Ordering::Relaxed), 1); - assert_eq!(oracle.stats.mismatches.load(Ordering::Relaxed), 1); - - assert!(!oracle.validate(INT4OID, &0i32.to_le_bytes(), "0").await); - assert_eq!(oracle.stats.probes.load(Ordering::Relaxed), 2); -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn oracle_resolve_reconnects_after_backend_restart() { if !pg_available() { @@ -340,7 +300,7 @@ async fn oracle_resolve_reconnects_after_backend_restart() { "postgres", "postgres", ); - let oracle = Oracle::connect(&conninfo, 0).await.expect("oracle connect"); + let oracle = Oracle::connect(&conninfo).await.expect("oracle connect"); use walshadow::schema::NUMERICOID; assert_eq!( oracle @@ -388,7 +348,7 @@ async fn oracle_resolve_errors_when_backend_down() { "postgres", "postgres", ); - let oracle = Oracle::connect(&conninfo, 0).await.expect("oracle connect"); + let oracle = Oracle::connect(&conninfo).await.expect("oracle connect"); use std::sync::atomic::Ordering; use walshadow::schema::NUMERICOID; assert!( diff --git a/tests/oracle_types_e2e.rs b/tests/oracle_types_e2e.rs index 299d9cb5..239ba774 100644 --- a/tests/oracle_types_e2e.rs +++ b/tests/oracle_types_e2e.rs @@ -9,8 +9,9 @@ #[path = "common/inproc_harness.rs"] mod fx; +use fx::spawn_txn; use std::path::Path; -use std::process::{Command, Stdio}; +use std::process::Command; use std::sync::Arc; use std::time::Duration; @@ -94,45 +95,6 @@ fn col(attnum: i16, name: &str, ty: &str) -> ColumnMapping { } } -fn spawn_txn(source: &Shadow, body: &str) -> std::thread::JoinHandle<()> { - let sock = source.config().socket_dir.clone(); - let port = source.config().port; - let sql = body.to_owned(); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(200)); - let mut child = Command::new("psql") - .args([ - "-h", - sock.to_str().unwrap(), - "-p", - &port.to_string(), - "-U", - "postgres", - "-d", - "postgres", - "-v", - "ON_ERROR_STOP=1", - "-f", - "-", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn psql"); - { - use std::io::Write as _; - child - .stdin - .as_mut() - .expect("stdin piped") - .write_all(sql.as_bytes()) - .unwrap(); - } - let _ = child.wait(); - }) -} - async fn run_oracle( slot: PortSlot, app_name: &str, @@ -163,7 +125,7 @@ async fn run_oracle( "postgres", "postgres", ); - let oracle = Oracle::connect(&conninfo, 0).await.expect("oracle connect"); + let oracle = Oracle::connect(&conninfo).await.expect("oracle connect"); assert!( oracle.has_extension(), "shadow must expose walshadow_decode_disk", diff --git a/tests/runtime_config_e2e.rs b/tests/runtime_config_e2e.rs index a8d10f39..3e34fc85 100644 --- a/tests/runtime_config_e2e.rs +++ b/tests/runtime_config_e2e.rs @@ -10,9 +10,10 @@ //! //! 2. `opt_out_mid_stream_drains_and_halts` //! * `app.orders` TOML-mapped and replicating. -//! * Operator inserts `config_table (replicate=false)` between two INSERTs. -//! * Expect: rows committed before the opt-out drain to CH, rows after -//! never emit, CH target retained. +//! * Operator inserts `config_table (replicate=false)` between two +//! multi-row INSERTs. +//! * Expect: the xact committed before the opt-out drains whole, the one +//! after never emits (no partial xact either side), CH target retained. //! //! 3. `forward_decl_materializes_on_create_table` //! * Operator inserts `config_table (replicate=true)` for a table that @@ -53,6 +54,13 @@ //! * Expect: the namespace flag alone auto-creates the CH table and the //! row lands. //! +//! 8. `pre_opt_in_xact_discards_post_opt_in_routes` +//! * No TOML mapping, no `initial_load`: a row committed before the +//! `config_table` opt-in plans against no route (counted discard), +//! a row committed after routes and lands. +//! * Route snapshots attach at planning: a transaction planned before +//! the opt-in never re-routes, one planned after routes whole. +//! //! Source-side `config_*` install runs the real `sql/runtime_config_install.sql` //! inside the bootstrap schema dump, so the drills double as install-script //! coverage (psql `\if` default-schema guard included). @@ -250,14 +258,20 @@ async fn opt_out_mid_stream_drains_and_halts() { // Commit order fixes semantics: id=1 precedes the opt-out (drains to CH), // id=2 follows it (never emits). The opt-out applies inside the barrier // fence, after id=1 is durable. + // Multi-row xacts on both sides of the boundary: whole-transaction route + // granularity means each side lands or discards as a unit, never partial. let driver = fx::spawn_workload( &source, vec![ - "INSERT INTO app.orders (id, note) VALUES (1, 'before opt-out')".into(), + "INSERT INTO app.orders (id, note) \ + SELECT i, 'before opt-out' FROM generate_series(1, 5) AS i" + .into(), "INSERT INTO walshadow.config_table (namespace, relname, replicate) \ VALUES ('app', 'orders', false)" .into(), - "INSERT INTO app.orders (id, note) VALUES (2, 'after opt-out')".into(), + "INSERT INTO app.orders (id, note) \ + SELECT i, 'after opt-out' FROM generate_series(6, 10) AS i" + .into(), "SELECT pg_switch_wal()".into(), ], ); @@ -273,17 +287,18 @@ async fn opt_out_mid_stream_drains_and_halts() { assert!(observed >= target); pipeline.shutdown().await.expect("pipeline drains clean"); - // Source has both rows; CH stopped at the opt-out boundary. + // Source has both xacts; CH stopped at the opt-out boundary with the + // before-xact complete — no partial transaction on either side. let src = source.psql_one("SELECT count(*) FROM app.orders").unwrap(); - assert_eq!(src, "2"); + assert_eq!(src, "10"); let n = ch .query("SELECT count() FROM walshadow_test.orders FINAL WHERE _is_deleted = 0") .expect("ch count"); - assert_eq!(n, "1", "row committed after replicate=false must not emit"); + assert_eq!(n, "5", "before-xact whole, after-xact absent"); let ids = ch - .query("SELECT id FROM walshadow_test.orders FINAL WHERE _is_deleted = 0") + .query("SELECT max(id) FROM walshadow_test.orders FINAL WHERE _is_deleted = 0") .expect("ch ids"); - assert_eq!(ids, "1", "only the pre-opt-out row reaches CH"); + assert_eq!(ids, "5", "no row committed after replicate=false emits"); // Target retained (opt-out is a routing change, not a DROP). let exists = ch @@ -860,3 +875,106 @@ async fn column_target_type_override_reaches_projection() { "override must drive the encode scale (a dropped override stores 123)" ); } + +/// Drill 8: transaction planned before the opt-in discards, one planned +/// after routes. No TOML mapping and no `initial_load`, so the pre-opt-in +/// row has exactly one path to CH — a route resolved at planning — and it +/// must not take it. The post-opt-in row proves the opt-in commit preceding +/// heap rows in WAL routes those rows. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn pre_opt_in_xact_discards_post_opt_in_routes() { + if !fx::pg_available() || !fx::pg_basebackup_available() || !fx::clickhouse_available() { + eprintln!("skip: missing initdb / pg_basebackup / clickhouse"); + return; + } + + let tmp = tempfile::tempdir().unwrap(); + let source_port = SOURCE_PORT + 70; + let shadow_port = SHADOW_PORT + 70; + let ch_tcp_port = CH_TCP_PORT + 70; + let ch_http_port = CH_HTTP_PORT + 70; + let walsender_port = WALSENDER_PORT + 70; + let schema_sql = format!( + "{INSTALL_SQL}\n\ + CREATE SCHEMA app;\n\ + CREATE TABLE app.metrics (id bigint PRIMARY KEY, v text);\n" + ); + let ( + fx::BootstrappedClusters { + source, + shadow, + shadow_filter_dir, + }, + shadow_stream_state, + ) = fx::bootstrap_clusters(&tmp, &schema_sql, source_port, shadow_port, walsender_port).await; + let _src_stop = fx::StopOnDrop { sh: &source }; + let _shd_stop = fx::StopOnDrop { sh: &shadow }; + + let ch_tmp = tempfile::tempdir().unwrap(); + let ch = fx::ChServer::spawn(ch_tmp, ch_tcp_port, ch_http_port).expect("spawn ch"); + ch.query("CREATE DATABASE IF NOT EXISTS walshadow_test") + .expect("create db"); + + let mut 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, + mappings: vec![], + app_name: "walshadow-config-pre-opt-in-discard", + ddl: Some(overlay_ddl_args()), + }) + .await; + + // Commit order fixes semantics: id=1 plans against no route (discard), + // the opt-in applies inside the barrier fence, id=2 plans after it. + let driver = fx::spawn_workload( + &source, + vec![ + "INSERT INTO app.metrics (id, v) VALUES (1, 'pre-opt-in')".into(), + "INSERT INTO walshadow.config_table (namespace, relname, replicate) \ + VALUES ('app', 'metrics', true)" + .into(), + "INSERT INTO app.metrics (id, v) VALUES (2, 'post-opt-in')".into(), + "SELECT pg_switch_wal()".into(), + ], + ); + + let shipped = fx::pump_segments(&mut pipeline, 1, Duration::from_secs(45)).await; + let _ = driver.join(); + assert!(shipped >= 1, "no segments shipped in 45s"); + + let target = pipeline.stream.dispatched_lsn(); + let observed = shadow + .wait_for_replay(target, Duration::from_secs(30)) + .expect("shadow replay"); + assert!(observed >= target); + let discarded = pipeline + .stats + .unsupported_relations + .load(std::sync::atomic::Ordering::Relaxed); + pipeline.shutdown().await.expect("pipeline drains clean"); + + assert!(discarded >= 1, "pre-opt-in xact must be a counted discard"); + + let n = ch + .query("SELECT count() FROM walshadow_test.metrics FINAL WHERE _is_deleted = 0") + .expect("ch count"); + assert_eq!(n, "1", "exactly the post-opt-in row lands"); + + let gone = ch + .query("SELECT count() FROM walshadow_test.metrics WHERE id = 1") + .expect("ch pre-opt-in row"); + assert_eq!(gone, "0", "pre-opt-in row must never reach CH"); + + let v = ch + .query( + "SELECT argMax(v, _lsn) FROM walshadow_test.metrics \ + WHERE _is_deleted = 0 AND id = 2", + ) + .expect("ch v"); + assert_eq!(v, "post-opt-in"); +} diff --git a/tests/shadow_catalog.rs b/tests/shadow_catalog.rs index f7c45a92..6b595707 100644 --- a/tests/shadow_catalog.rs +++ b/tests/shadow_catalog.rs @@ -1,16 +1,16 @@ -//! `ShadowCatalog` end-to-end against a live shadow PG. +//! `ShadowCatalog` end-to-end against a live shadow PG: connect/reconnect, +//! replay gate, name-keyed resolution, and the batched descriptor fetch +//! that feeds descriptor-log capture. //! //! Skipped silently if `initdb` is not on `$PATH`. Each test spins up a //! fresh data directory under a tempdir; tests pick non-overlapping //! ports so cargo's parallel runner doesn't collide them. use std::process::Command; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, Instant}; +use std::time::Duration; use walshadow::pg::socket_conninfo; -use walshadow::schema::{RelName, ReplIdent, SchemaEvent}; +use walshadow::schema::{RelName, ReplIdent}; use walshadow::shadow::{Shadow, ShadowConfig}; use walshadow::shadow_catalog::{ CatalogError, ShadowCatalog, ShadowCatalogConfig, with_transient_retry, @@ -66,12 +66,12 @@ async fn open_catalog(shadow: &Shadow, replay_timeout: Duration) -> ShadowCatalo .expect("catalog connect") } -fn pg_class_filenode_via_psql(shadow: &Shadow) -> u32 { +fn relation_oid(shadow: &Shadow, qualified: &str) -> u32 { shadow - .psql_one("SELECT pg_relation_filenode('pg_class'::regclass)::int8") - .expect("psql pg_class filenode") + .psql_one(&format!("SELECT '{qualified}'::regclass::oid::int8")) + .expect("psql relation oid") .parse() - .expect("filenode is integer") + .expect("oid is integer") } fn user_relation_filenode(shadow: &Shadow, qualified: &str) -> u32 { @@ -84,14 +84,6 @@ fn user_relation_filenode(shadow: &Shadow, qualified: &str) -> u32 { .expect("filenode is integer") } -fn relation_oid(shadow: &Shadow, qualified: &str) -> u32 { - shadow - .psql_one(&format!("SELECT '{qualified}'::regclass::oid::int8")) - .expect("psql relation oid") - .parse() - .expect("oid is integer") -} - fn current_db_oid(shadow: &Shadow) -> u32 { shadow .psql_one("SELECT oid::int8 FROM pg_database WHERE datname = current_database()") @@ -100,67 +92,8 @@ fn current_db_oid(shadow: &Shadow) -> u32 { .expect("db oid is integer") } -fn pg_global_tablespace_oid() -> u32 { - // pg_global is always oid 1664. - 1664 -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn catalog_relation_lookup_by_filenode() { - if !pg_available() { - eprintln!("skip: no initdb on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let shadow = make_shadow(&tmp, 55601); - shadow.initdb().expect("initdb"); - shadow.write_base_conf().expect("conf"); - shadow.start().expect("start"); - let _stop = stop_on_drop(&shadow); - - let pg_class_filenode = pg_class_filenode_via_psql(&shadow); - let db = current_db_oid(&shadow); - - let mut cat = open_catalog(&shadow, Duration::from_secs(5)).await; - - let rfn = walrus::pg::walparser::RelFileNode { - spc_node: pg_global_tablespace_oid(), - db_node: db, - rel_node: pg_class_filenode, - }; - let desc = cat.relation_at(rfn, 0).await.expect("relation_at pg_class"); - assert_eq!(&*desc.rel_name.name, "pg_class"); - assert_eq!(&*desc.rel_name.namespace, "pg_catalog"); - assert_eq!(desc.kind, 'r'); - assert_eq!(desc.persistence, 'p'); - assert!( - desc.attributes.iter().any(|a| a.name == "relname"), - "pg_class must have relname column; got {:?}", - desc.attributes.iter().map(|a| &a.name).collect::>(), - ); - assert!( - desc.attributes.iter().any(|a| a.name == "oid"), - "pg_class must expose oid column", - ); - let nspname_oid_col = desc - .attributes - .iter() - .find(|a| a.name == "relnamespace") - .expect("relnamespace col"); - // oid type oid is 26. - assert_eq!(nspname_oid_col.type_oid, 26); - assert!(nspname_oid_col.not_null); - - // Second lookup must come from cache. - let before = cat.stats().clone(); - let _again = cat.relation_at(rfn, 0).await.expect("relation_at cached"); - let after = cat.stats().clone(); - assert_eq!(after.hits, before.hits + 1, "second lookup should be a hit"); - assert_eq!(after.fetches, before.fetches, "no extra fetch on hit"); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn user_relation_lookup_and_invalidation() { +async fn user_relation_lookup_by_name() { if !pg_available() { eprintln!("skip: no initdb on PATH"); return; @@ -183,71 +116,32 @@ async fn user_relation_lookup_and_invalidation() { ) .expect("apply schema dump"); - let filenode = user_relation_filenode(&shadow, "wc.things"); - let db = current_db_oid(&shadow); - // Default user tablespace is pg_default (oid 1663). - let rfn = walrus::pg::walparser::RelFileNode { - spc_node: 1663, - db_node: db, - rel_node: filenode, - }; - let mut cat = open_catalog(&shadow, Duration::from_secs(5)).await; - let desc = cat.relation_at(rfn, 0).await.expect("relation_at things"); + let desc = cat + .descriptor_by_name(&RelName::new("wc", "things")) + .await + .expect("descriptor_by_name") + .expect("wc.things exists"); assert_eq!(&*desc.rel_name.name, "things"); assert_eq!(&*desc.rel_name.namespace, "wc"); assert_eq!(desc.kind, 'r'); - // id, name, payload — three user columns (pg ≥ 12 dropped system cols - // from attnum >= 1 visibility). assert_eq!(desc.attributes.len(), 3, "{:?}", desc.attributes); let id_col = &desc.attributes[0]; assert_eq!(id_col.name, "id"); - // int8 type oid = 20 assert_eq!(id_col.type_oid, 20); assert!(id_col.not_null); - let name_col = &desc.attributes[1]; - assert_eq!(name_col.name, "name"); - // text type oid = 25 - assert_eq!(name_col.type_oid, 25); - assert!(name_col.not_null); let payload_col = &desc.attributes[2]; assert_eq!(payload_col.name, "payload"); - // jsonb type oid = 3802 assert_eq!(payload_col.type_oid, 3802); assert!(!payload_col.not_null); - // Cache hit on repeat lookup. - let first_misses = cat.stats().misses; - let _ = cat.relation_at(rfn, 0).await.unwrap(); - assert_eq!( - cat.stats().misses, - first_misses, - "second lookup should not miss" - ); - - // Generation bump → forced refetch. - let gen_before = cat.generation(); - cat.invalidate(); - assert_eq!(cat.generation(), gen_before + 1); - let fetches_before = cat.stats().fetches; - let again = cat - .relation_at(rfn, 0) - .await - .expect("relation_at after invalidate"); - assert_eq!(&*again.rel_name.name, "things"); - assert_eq!( - cat.stats().fetches, - fetches_before + 1, - "invalidate must force a re-fetch on next access", + assert!( + cat.descriptor_by_name(&RelName::new("wc", "ghost")) + .await + .expect("lookup runs") + .is_none(), + "unknown rel resolves None (forward-declaration parking)", ); - - // by-oid path round-trips back to the same descriptor. - let by_oid = cat - .relation_by_oid(desc.oid) - .await - .expect("relation_by_oid"); - assert_eq!(&*by_oid.rel_name.name, "things"); - assert_eq!(by_oid.rfn.rel_node, filenode); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -293,55 +187,30 @@ async fn catalog_reconnects_after_pg_restart() { let _stop = stop_on_drop(&shadow); let mut cat = open_catalog(&shadow, Duration::from_secs(10)).await; - - let pg_class_filenode = pg_class_filenode_via_psql(&shadow); - let pg_namespace_filenode = user_relation_filenode(&shadow, "pg_namespace"); - let db = current_db_oid(&shadow); - let rfn_class = walrus::pg::walparser::RelFileNode { - spc_node: pg_global_tablespace_oid(), - db_node: db, - rel_node: pg_class_filenode, - }; - let rfn_namespace = walrus::pg::walparser::RelFileNode { - spc_node: pg_global_tablespace_oid(), - db_node: db, - rel_node: pg_namespace_filenode, - }; let first = cat - .relation_at(rfn_class, 0) + .descriptor_by_name(&RelName::new("pg_catalog", "pg_class")) .await - .expect("relation_at pre-restart"); + .expect("pre-restart lookup") + .expect("pg_class exists"); assert_eq!(&*first.rel_name.name, "pg_class"); - let gen_before = cat.generation(); let reconnects_before = cat.stats().reconnects; - let bumps_before = cat.stats().generation_bumps; // pg_ctl-style restart: stop, then start. Server-side close drops - // the libpq connection; the next SQL call has to reconnect. Use a - // different rfn post-restart so the cache miss forces a fetch - // (same rfn would hit cache and never touch the dead connection). + // the libpq connection; the next SQL call has to reconnect. shadow.stop().expect("stop"); shadow.start().expect("restart"); let after = cat - .relation_at(rfn_namespace, 0) + .descriptor_by_name(&RelName::new("pg_catalog", "pg_namespace")) .await - .expect("relation_at post-restart"); + .expect("post-restart lookup") + .expect("pg_namespace exists"); assert_eq!(&*after.rel_name.name, "pg_namespace"); - assert!( - cat.generation() > gen_before, - "reconnect must bump generation (was {gen_before}, now {})", - cat.generation(), - ); assert!( cat.stats().reconnects > reconnects_before, "reconnect counter must advance (was {reconnects_before}, now {})", cat.stats().reconnects, ); - assert!( - cat.stats().generation_bumps > bumps_before, - "reconnect bumps cache generation alongside the reconnect counter", - ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -413,18 +282,19 @@ async fn with_transient_retry_outlasts_a_pg_restart() { drop(cat); } +/// Physical fidelity: dropped columns stay in `attributes` as dropped slots +/// (attnum-1 indexing preserved) with attlen/attalign carried from +/// pg_attribute — the pg_type row link is gone (atttypid = 0). Exercises +/// the name-keyed path and `fetch_descriptors_batch` returning identical +/// shape. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn tracker_signal_drives_invalidate_and_refetches_after_ddl() { - // Production-path verification of the shared - // `invalidation_epoch` AtomicU64: an upstream bump triggers an - // inline `invalidate` at the top of `relation_at`. Closes the - // race-prone mpsc-drain wire it replaces. +async fn dropped_column_keeps_physical_slot() { if !pg_available() { eprintln!("skip: no initdb on PATH"); return; } let tmp = tempfile::tempdir().unwrap(); - let shadow = make_shadow(&tmp, 55607); + let shadow = make_shadow(&tmp, 55613); shadow.initdb().expect("initdb"); shadow.write_base_conf().expect("conf"); shadow.start().expect("start"); @@ -433,325 +303,54 @@ async fn tracker_signal_drives_invalidate_and_refetches_after_ddl() { shadow .apply_schema_dump( "CREATE SCHEMA wc;\n\ - CREATE TABLE wc.things (\n\ - id bigint PRIMARY KEY,\n\ - name text NOT NULL\n\ - );\n", + CREATE TABLE wc.evolve (id bigint PRIMARY KEY, gone text, kept int);\n\ + ALTER TABLE wc.evolve DROP COLUMN gone;\n\ + ALTER TABLE wc.evolve ADD COLUMN body text;\n", ) .expect("schema dump"); - let filenode = user_relation_filenode(&shadow, "wc.things"); let db = current_db_oid(&shadow); - let rfn = walrus::pg::walparser::RelFileNode { - spc_node: 1663, - db_node: db, - rel_node: filenode, - }; - + let oid = relation_oid(&shadow, "wc.evolve"); + let filenode = user_relation_filenode(&shadow, "wc.evolve"); let mut cat = open_catalog(&shadow, Duration::from_secs(5)).await; - let epoch = Arc::new(AtomicU64::new(0)); - cat.set_invalidation_epoch(epoch.clone()); - - // Prime the cache so the post-DDL re-fetch is what surfaces the - // new column (the bug being fixed: without invalidate, the cached - // descriptor would keep masking ADD COLUMN). - let desc = cat.relation_at(rfn, 0).await.expect("prime"); - assert_eq!(desc.attributes.len(), 2); - assert!(desc.attributes.iter().all(|a| a.name != "extra")); - - // DDL through psql so the live shadow's catalog actually changes. - shadow - .apply_schema_dump("ALTER TABLE wc.things ADD COLUMN extra text;") - .expect("alter table"); - - let bumps_before = cat.stats().generation_bumps; - // Production tracker bumps the epoch on observed pg_class writes. - // Simulate one bump and call relation_at: the catalog must observe - // the delta in `drain_invalidations` and invalidate before the - // cache check. - epoch.fetch_add(1, Ordering::Release); - - let fresh = cat.relation_at(rfn, 0).await.expect("relation_at post-DDL"); - assert_eq!( - cat.stats().generation_bumps, - bumps_before + 1, - "epoch bump must trigger one invalidate on next lookup", - ); - assert_eq!( - fresh.attributes.len(), - 3, - "post-DDL fetch must surface ADD COLUMN; got {:?}", - fresh.attributes.iter().map(|a| &a.name).collect::>(), - ); - assert!( - fresh.attributes.iter().any(|a| a.name == "extra"), - "added column must appear in attributes", - ); -} - -/// Baseline seed makes a pinned relation's first post-start ALTER emit -/// `Changed`, not `Added`. The executable form of the invariant in -/// `plans/future/pinned_ddl_baseline.md`: cache warmth must not decide -/// the schema event. `seeded` is warmed by `seed_baseline` before -/// `subscribe()`; `cold` is not. Both get the same ADD COLUMN; the -/// seeded table surfaces a `Changed` (→ CH ALTER), the cold table a -/// stale `Added` (→ apply_added skips a pinned dest — the bug). -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn seed_baseline_makes_first_alter_emit_changed_not_added() { - if !pg_available() { - eprintln!("skip: no initdb on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let shadow = make_shadow(&tmp, 55610); - shadow.initdb().expect("initdb"); - shadow.write_base_conf().expect("conf"); - shadow.start().expect("start"); - let _stop = stop_on_drop(&shadow); - - shadow - .apply_schema_dump( - "CREATE SCHEMA wc;\n\ - CREATE TABLE wc.seeded (id bigint PRIMARY KEY, name text);\n\ - CREATE TABLE wc.cold (id bigint PRIMARY KEY, name text);\n", - ) - .expect("schema dump"); - - let seeded_oid = relation_oid(&shadow, "wc.seeded"); - let cold_oid = relation_oid(&shadow, "wc.cold"); - - let mut cat = open_catalog(&shadow, Duration::from_secs(5)).await; - let epoch = Arc::new(AtomicU64::new(0)); - cat.set_invalidation_epoch(epoch.clone()); - - // Warm prev_known for `wc.seeded` only. Pre-subscribe, so no event - // leaks; `wc.cold` stays cold. - let seeded = cat - .seed_baseline(&[RelName::new("wc", "seeded")]) - .await - .expect("seed_baseline"); - assert_eq!(seeded, 1, "exactly one mapped relation seeded"); - - let mut rx = cat.subscribe(); - assert!( - rx.try_recv().is_err(), - "seed ran before subscribe — no event must have leaked", - ); - - // Same DDL on both tables. - shadow - .apply_schema_dump( - "ALTER TABLE wc.seeded ADD COLUMN extra text;\n\ - ALTER TABLE wc.cold ADD COLUMN extra text;\n", - ) - .expect("alter both"); - // Production tracker bumps the epoch on observed pg_class writes; - // simulate one so the next lookup invalidates and refetches. - epoch.fetch_add(1, Ordering::Release); - - // Seeded relation: refetch diffs the evolved shape against the warm - // baseline → Changed carrying the added column. - let _ = cat - .relation_by_oid(seeded_oid) - .await - .expect("refetch seeded"); - match rx.try_recv().expect("seeded must emit one event") { - SchemaEvent::Changed { diff, .. } => { - assert!( - diff.added_columns.iter().any(|a| a.name == "extra"), - "Changed diff must carry the added column; got {diff:?}", - ); - } - other => panic!("seeded: expected Changed, got {other:?}"), - } - assert!( - rx.try_recv().is_err(), - "seeded relation must emit exactly one event", - ); - - // Cold relation: first-ever fetch carries the post-ALTER shape and - // prev_known is empty → Added. This is the pre-fix behaviour the - // seed eliminates for pinned tables. - let _ = cat.relation_by_oid(cold_oid).await.expect("fetch cold"); - match rx.try_recv().expect("cold must emit one event") { - SchemaEvent::Added { desc } => { - assert!( - desc.attributes.iter().any(|a| a.name == "extra"), - "cold Added carries the already-evolved shape", - ); - } - other => panic!("cold: expected Added, got {other:?}"), - } -} - -/// `seed_baseline` warms each owner's toast rel into `prev_known`, so an -/// owner DROP with no interim chunk decode (the cold-restart state) still -/// surfaces the toast rel's `Dropped` via `sweep_dropped`, the event the -/// reorder coordinator retires the CH chunk mirror on. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn seed_baseline_warms_toast_rel_for_drop_sweep() { - if !pg_available() { - eprintln!("skip: no initdb on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let shadow = make_shadow(&tmp, 55611); - shadow.initdb().expect("initdb"); - shadow.write_base_conf().expect("conf"); - shadow.start().expect("start"); - let _stop = stop_on_drop(&shadow); - - shadow - .apply_schema_dump( - "CREATE SCHEMA wc;\n\ - CREATE TABLE wc.doc (id bigint PRIMARY KEY, body text);\n", - ) - .expect("schema dump"); - let owner_oid = relation_oid(&shadow, "wc.doc"); - let toast_oid: u32 = shadow - .psql_one("SELECT reltoastrelid::int8 FROM pg_class WHERE oid = 'wc.doc'::regclass") - .expect("psql toast relid") - .parse() - .expect("toast oid is integer"); - assert_ne!(toast_oid, 0, "text column must give wc.doc a toast rel"); - - let mut cat = open_catalog(&shadow, Duration::from_secs(5)).await; - let seeded = cat - .seed_baseline(&[RelName::new("wc", "doc")]) + let desc = cat + .descriptor_by_name(&RelName::new("wc", "evolve")) .await - .expect("seed_baseline"); - assert_eq!(seeded, 1, "seeded counts owners, not toast rels"); - let mut rx = cat.subscribe(); - assert!( - rx.try_recv().is_err(), - "seed ran before subscribe — no event must have leaked", - ); - - shadow - .apply_schema_dump("DROP TABLE wc.doc;") - .expect("drop table"); - - let dropped = cat.sweep_dropped().await.expect("sweep_dropped"); - assert_eq!(dropped, 2, "owner + toast rel must both surface"); - let mut got: Vec<(u32, String)> = Vec::new(); - while let Ok(ev) = rx.try_recv() { - let SchemaEvent::Dropped { oid, rel_name } = ev else { - panic!("expected Dropped, got {ev:?}"); - }; - got.push((oid, rel_name.namespace.to_string())); - } - got.sort_unstable(); - let mut want = vec![ - (owner_oid, "wc".to_string()), - (toast_oid, "pg_toast".to_string()), - ]; - want.sort_unstable(); - assert_eq!(got, want, "toast Dropped must carry the pg_toast namespace"); -} - -/// Index descriptors resolve (stash `Skip` verdicts need them) but stay out -/// of the event stream and `sweep_dropped`: a pg_toast index surfacing as -/// `Dropped` would double-count the chunk-mirror retire at owner DROP. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn index_descriptor_emits_no_events_and_skips_drop_sweep() { - if !pg_available() { - eprintln!("skip: no initdb on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let shadow = make_shadow(&tmp, 55612); - shadow.initdb().expect("initdb"); - shadow.write_base_conf().expect("conf"); - shadow.start().expect("start"); - let _stop = stop_on_drop(&shadow); - - shadow - .apply_schema_dump( - "CREATE SCHEMA wc;\n\ - CREATE TABLE wc.doc (id bigint PRIMARY KEY, body text);\n", - ) - .expect("schema dump"); - - let owner_oid = relation_oid(&shadow, "wc.doc"); - let pkey_rfn: u32 = shadow - .psql_one("SELECT relfilenode::int8 FROM pg_class WHERE oid = 'wc.doc_pkey'::regclass") - .expect("pkey relfilenode") - .parse() - .expect("relfilenode is integer"); - let toast_index_rfn: u32 = shadow - .psql_one( - "SELECT i.relfilenode::int8 FROM pg_class c \ - JOIN pg_index x ON x.indrelid = c.reltoastrelid \ - JOIN pg_class i ON i.oid = x.indexrelid \ - WHERE c.oid = 'wc.doc'::regclass", - ) - .expect("toast index relfilenode") - .parse() - .expect("relfilenode is integer"); - - let mut cat = open_catalog(&shadow, Duration::from_secs(5)).await; - cat.seed_baseline(&[RelName::new("wc", "doc")]) + .expect("descriptor_by_name") + .expect("wc.evolve exists"); + // id(1), dropped slot(2), kept(3), body(4) + assert_eq!(desc.attributes.len(), 4, "{:?}", desc.attributes); + let slot = &desc.attributes[1]; + assert_eq!(slot.attnum, 2); + assert!(slot.dropped); + assert_eq!(slot.type_oid, 0, "DROP COLUMN zeroes atttypid"); + assert_eq!(slot.type_name, ""); + // text physical layout survives the drop: varlena, int-aligned, extended + assert_eq!(slot.type_len, -1); + assert_eq!(slot.type_align, 'i'); + assert_eq!(slot.type_storage, 'x'); + assert_eq!(desc.attributes[2].attnum, 3); + assert!(!desc.attributes[2].dropped); + assert_ne!(desc.toast_oid, 0, "text column gives wc.evolve a toast rel"); + + let (replay_lsn, batch) = cat + .fetch_descriptors_batch(&[oid, 99_999_999]) .await - .expect("seed_baseline"); - let mut rx = cat.subscribe(); - for rfn in [pkey_rfn, toast_index_rfn] { - let desc = cat - .relation_at( - walrus::pg::walparser::RelFileNode { - spc_node: 1663, - db_node: current_db_oid(&shadow), - rel_node: rfn, - }, - 0, - ) - .await - .expect("index resolves by filenode"); - assert_eq!(desc.kind, 'i'); - } - assert!( - rx.try_recv().is_err(), - "index descriptor loads must emit no Added", - ); - - shadow - .apply_schema_dump("DROP TABLE wc.doc;") - .expect("drop table"); - let dropped = cat.sweep_dropped().await.expect("sweep_dropped"); - assert_eq!(dropped, 2, "owner + toast rel only; indexes never tracked"); - let mut got: Vec = Vec::new(); - while let Ok(ev) = rx.try_recv() { - let SchemaEvent::Dropped { rel_name, .. } = ev else { - panic!("expected Dropped, got {ev:?}"); - }; - got.push(format!("{}.{}", rel_name.namespace, rel_name.name)); - } - got.sort_unstable(); - let toast_name = format!("pg_toast.pg_toast_{owner_oid}"); - assert_eq!(got, vec![toast_name, "wc.doc".to_string()]); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn nonexistent_filenode_errors_not_found() { - if !pg_available() { - eprintln!("skip: no initdb on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let shadow = make_shadow(&tmp, 55604); - shadow.initdb().expect("initdb"); - shadow.write_base_conf().expect("conf"); - shadow.start().expect("start"); - let _stop = stop_on_drop(&shadow); - - let mut cat = open_catalog(&shadow, Duration::from_secs(2)).await; - let bogus = walrus::pg::walparser::RelFileNode { - spc_node: 1663, - db_node: current_db_oid(&shadow), - rel_node: 99_999_999, - }; - let err = cat.relation_at(bogus, 0).await.expect_err("bogus filenode"); - matches!(err, CatalogError::NotFoundByFilenode(_)); + .expect("fetch_descriptors_batch"); + // Not a standby here: pg_last_wal_replay_lsn() is NULL → 0 + assert_eq!(replay_lsn, 0); + assert_eq!(batch.len(), 1, "absent oid must be absent, not error"); + let b = &batch[0]; + assert_eq!(b.oid, oid); + assert_eq!(b.rfn.db_node, db); + assert_eq!(b.rfn.rel_node, filenode); + // reltablespace = 0 sentinel resolves to dattablespace (pg_default + // here), matching WAL locators' physical spcOid + assert_eq!(b.rfn.spc_node, 1663); + assert_eq!(b.toast_oid, desc.toast_oid); + assert_eq!(b.replident, desc.replident); + assert_eq!(b.attributes, desc.attributes); } /// Replica-identity carriage: `RelDescriptor::replident` carries the resolved @@ -802,52 +401,43 @@ async fn replident_matrix_default_nothing_full_index() { ) .expect("schema dump"); - let db = current_db_oid(&shadow); let mut cat = open_catalog(&shadow, Duration::from_secs(5)).await; let cases = [ ( - "wc.def_t", + "def_t", ReplIdent::Default { pk_attnums: Some(vec![1]), }, ), - ("wc.no_pk_t", ReplIdent::Default { pk_attnums: None }), + ("no_pk_t", ReplIdent::Default { pk_attnums: None }), ( - "wc.composite_pk_t", + "composite_pk_t", ReplIdent::Default { pk_attnums: Some(vec![1, 2]), }, ), - ("wc.nothing_t", ReplIdent::Nothing), - ("wc.full_t", ReplIdent::Full { pk_attnums: None }), + ("nothing_t", ReplIdent::Nothing), + ("full_t", ReplIdent::Full { pk_attnums: None }), ]; - for (qualified, expected) in cases { - let rfn = walrus::pg::walparser::RelFileNode { - spc_node: 1663, - db_node: db, - rel_node: user_relation_filenode(&shadow, qualified), - }; + for (name, expected) in cases { let desc = cat - .relation_at(rfn, 0) + .descriptor_by_name(&RelName::new("wc", name)) .await - .unwrap_or_else(|e| panic!("relation_at {qualified}: {e}")); + .unwrap_or_else(|e| panic!("descriptor_by_name wc.{name}: {e}")) + .unwrap_or_else(|| panic!("wc.{name} missing")); assert_eq!( desc.replident, expected, - "{qualified}: expected {expected:?}, got {:?}", + "wc.{name}: expected {expected:?}, got {:?}", desc.replident, ); } - let rfn_idx = walrus::pg::walparser::RelFileNode { - spc_node: 1663, - db_node: db, - rel_node: user_relation_filenode(&shadow, "wc.idx_t"), - }; let desc_idx = cat - .relation_at(rfn_idx, 0) + .descriptor_by_name(&RelName::new("wc", "idx_t")) .await - .expect("relation_at idx_t"); + .expect("descriptor_by_name idx_t") + .expect("idx_t exists"); let (index_oid, key_attnums) = match desc_idx.replident.clone() { ReplIdent::UsingIndex { index_oid, @@ -869,20 +459,16 @@ async fn replident_matrix_default_nothing_full_index() { ); } -/// Arc-mutex sanity check: with the catalog wrapped in -/// `Arc>` at the daemon level, two tasks holding -/// clones of the same `Arc` serialise cleanly across `relation_at`. -/// Validates the wrap shape `BufferingDecoderSink` relies on, not the -/// lock-free hit path (that lands when the spec'd `&self` refactor -/// follows up). +/// `fetch_all_descriptors` (capture-all + boot seed) covers every eligible +/// user rel incl toast, skips indexes/views/sequences. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn arc_mutex_catalog_serialises_relation_at_across_tasks() { +async fn fetch_all_descriptors_covers_eligible_kinds() { if !pg_available() { eprintln!("skip: no initdb on PATH"); return; } let tmp = tempfile::tempdir().unwrap(); - let shadow = make_shadow(&tmp, 55608); + let shadow = make_shadow(&tmp, 55601); shadow.initdb().expect("initdb"); shadow.write_base_conf().expect("conf"); shadow.start().expect("start"); @@ -891,87 +477,37 @@ async fn arc_mutex_catalog_serialises_relation_at_across_tasks() { shadow .apply_schema_dump( "CREATE SCHEMA wc;\n\ - CREATE TABLE wc.things (\n\ - id bigint PRIMARY KEY,\n\ - name text NOT NULL\n\ - );\n", + CREATE TABLE wc.doc (id bigint PRIMARY KEY, body text);\n\ + CREATE VIEW wc.v AS SELECT id FROM wc.doc;\n\ + CREATE SEQUENCE wc.seq;\n", ) .expect("schema dump"); - let pg_class_filenode = pg_class_filenode_via_psql(&shadow); - let things_filenode = user_relation_filenode(&shadow, "wc.things"); - let db = current_db_oid(&shadow); - let rfn_class = walrus::pg::walparser::RelFileNode { - spc_node: pg_global_tablespace_oid(), - db_node: db, - rel_node: pg_class_filenode, - }; - let rfn_things = walrus::pg::walparser::RelFileNode { - spc_node: 1663, - db_node: db, - rel_node: things_filenode, - }; - - let cat = open_catalog(&shadow, Duration::from_secs(5)).await; - let cat = Arc::new(tokio::sync::Mutex::new(cat)); - - // Task A acquires the lock and holds it across an await on a small - // sleep, so task B starts its lookup against a held mutex. The - // tokio::sync::Mutex is fair-ish and async — task B must wait for - // A to drop the guard, then succeed. Anything else (panic, hang, - // "would deadlock") fails the test. - let cat_a = cat.clone(); - let cat_b = cat.clone(); - let task_a = tokio::spawn(async move { - let mut guard = cat_a.lock().await; - let desc = guard - .relation_at(rfn_class, 0) - .await - .expect("relation_at pg_class from task A"); - // Hold the guard across an await so task B has to wait. - tokio::time::sleep(Duration::from_millis(50)).await; - let again = guard - .relation_at(rfn_class, 0) - .await - .expect("relation_at pg_class second-call from task A"); - assert_eq!(&*desc.rel_name.name, "pg_class"); - assert_eq!(&*again.rel_name.name, "pg_class"); - desc.oid - }); - let task_b = tokio::spawn(async move { - // Yield once so task A wins the lock first. - tokio::task::yield_now().await; - let mut guard = cat_b.lock().await; - let desc = guard - .relation_at(rfn_things, 0) - .await - .expect("relation_at wc.things from task B"); - assert_eq!(&*desc.rel_name.name, "things"); - desc.oid - }); - let started = Instant::now(); - let (oid_a, oid_b) = tokio::join!(task_a, task_b); - let elapsed = started.elapsed(); - let oid_a = oid_a.expect("task A finished"); - let oid_b = oid_b.expect("task B finished"); - assert_ne!(oid_a, 0); - assert_ne!(oid_b, 0); - assert_ne!( - oid_a, oid_b, - "pg_class and wc.things must have different oids" - ); - // Bound the wall clock to a generous ceiling so a hang surfaces. + let mut cat = open_catalog(&shadow, Duration::from_secs(5)).await; + let (_, descs) = cat + .fetch_all_descriptors() + .await + .expect("fetch_all_descriptors"); + let doc = descs + .iter() + .find(|d| &*d.rel_name.name == "doc") + .expect("wc.doc present"); + assert_eq!(doc.kind, 'r'); + assert_ne!(doc.toast_oid, 0); assert!( - elapsed < Duration::from_secs(5), - "cross-task relation_at took too long: {elapsed:?}", + descs + .iter() + .any(|d| d.oid == doc.toast_oid && d.kind == 't'), + "owner's toast rel captured alongside it", ); - - // Final state: both descriptors cached, no surprise reconnects. - let guard = cat.lock().await; - assert!(guard.cached() >= 2, "cached={}", guard.cached()); - assert_eq!( - guard.stats().reconnects, - 0, - "no reconnect should fire on a steady-state shadow", + assert!( + descs + .iter() + .all(|d| matches!(d.kind, 'r' | 'p' | 'm' | 't')), + "views/sequences/indexes excluded: {:?}", + descs + .iter() + .map(|d| (d.rel_name.name.clone(), d.kind)) + .collect::>(), ); } diff --git a/tests/soft_delete_cdc.rs b/tests/soft_delete_cdc.rs index 2a6abb51..efb6681a 100644 --- a/tests/soft_delete_cdc.rs +++ b/tests/soft_delete_cdc.rs @@ -5,7 +5,7 @@ #[path = "common/inproc_harness.rs"] mod fx; -use std::process::{Command, Stdio}; +use fx::spawn_txn; use std::time::Duration; use walshadow::mapping::ColumnMapping; @@ -103,45 +103,6 @@ fn create_ch_dest(ch: &fx::ChServer) { .expect("create dest table"); } -fn spawn_txn(source: &Shadow, body: &str) -> std::thread::JoinHandle<()> { - let sock = source.config().socket_dir.clone(); - let port = source.config().port; - let sql = body.to_owned(); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(200)); - let mut child = Command::new("psql") - .args([ - "-h", - sock.to_str().unwrap(), - "-p", - &port.to_string(), - "-U", - "postgres", - "-d", - "postgres", - "-v", - "ON_ERROR_STOP=1", - "-f", - "-", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn psql"); - { - use std::io::Write as _; - child - .stdin - .as_mut() - .expect("stdin piped") - .write_all(sql.as_bytes()) - .unwrap(); - } - let _ = child.wait(); - }) -} - async fn run_drill( slot: PortSlot, app_name: &str, diff --git a/tests/subxact.rs b/tests/subxact.rs index 5de630b2..6de7e8c1 100644 --- a/tests/subxact.rs +++ b/tests/subxact.rs @@ -20,7 +20,7 @@ #[path = "common/inproc_harness.rs"] mod fx; -use std::process::{Command, Stdio}; +use fx::spawn_txn; use std::time::Duration; use walshadow::mapping::TableTarget; @@ -135,44 +135,6 @@ fn create_ch_dest(ch: &fx::ChServer) { .expect("create dest table"); } -/// Drive a single explicit transaction containing multi-line SQL. -/// Unlike `fx::spawn_workload` (which uses per-statement `-c` autocommit), -/// this routes through `-f -` piping so SAVEPOINT lineage survives. -fn spawn_txn(source: &Shadow, body: &str) -> std::thread::JoinHandle<()> { - let sock = source.config().socket_dir.clone(); - let port = source.config().port; - let sql = body.to_owned(); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(200)); - let mut child = Command::new("psql") - .args([ - "-h", - sock.to_str().unwrap(), - "-p", - &port.to_string(), - "-U", - "postgres", - "-d", - "postgres", - "-v", - "ON_ERROR_STOP=1", - "-f", - "-", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn psql"); - { - use std::io::Write as _; - let stdin = child.stdin.as_mut().expect("stdin piped"); - stdin.write_all(sql.as_bytes()).unwrap(); - } - let _ = child.wait(); - }) -} - /// Shared drill — bootstrap clusters, pump the workload, then return /// the (source, ch) handles to the caller for assertions. async fn run_drill std::thread::JoinHandle<()>>( diff --git a/tests/toast_rewrite_e2e.rs b/tests/toast_rewrite_e2e.rs index 14ac89d9..2f20bb03 100644 --- a/tests/toast_rewrite_e2e.rs +++ b/tests/toast_rewrite_e2e.rs @@ -318,8 +318,9 @@ async fn vacuum_full_rewrite_and_same_xact_stash() { // Same-xact TRUNCATE + toasted INSERT (single psql -c = one xact): // post-truncate chunks ride the toast rel's new filenode, stash, and - // decode at commit past the mirror wipe. Main tuples stay fenced. - let skipped_before = stats.toast_stash_skipped.load(Ordering::Relaxed); + // decode at commit past the mirror wipe. Main tuple decodes raw and + // lands after the dest truncate. + let decoded_before: u64 = stats.raw_decode_ordinary_ops.load().iter().sum(); let driver = fx::spawn_workload( &source, vec![ @@ -345,16 +346,17 @@ async fn vacuum_full_rewrite_and_same_xact_stash() { .await; assert_eq!(stats.toast_mirror_truncates.load(Ordering::Relaxed), 1); assert!( - stats.toast_stash_skipped.load(Ordering::Relaxed) > skipped_before, - "fenced main tuples counted as skipped" + stats.raw_decode_ordinary_ops.load().iter().sum::() > decoded_before, + "post-truncate main tuple decodes from the raw stash" ); - // Destination truncate applied; the post-truncate main row stays fenced - // until a replay fence exists. + // Destination truncate applied, then the same-xact row lands after it + // (event-before-heap tie break). fx::wait_query( &ch, - "SELECT count() FROM walshadow_test.doc", - "0", - "dest table should be empty after the truncate barrier", + "SELECT concat(toString(count()), '/', sum(length(body))) \ + FROM walshadow_test.doc WHERE _is_deleted = 0", + &format!("1/{BODY_D_LEN}"), + "post-truncate row is the only survivor, body rehydrated whole", ) .await; @@ -444,7 +446,11 @@ async fn vacuum_full_rewrite_and_same_xact_stash() { let decoder_stats = pipeline.sinks.decoder.stats_handle(); pipeline.shutdown().await.expect("pipeline drains clean"); - assert!(decoder_stats.toast_stash_buffered.load(Ordering::Relaxed) > 0); + // Same-xact stash admission now rides the catalog-dirty defer branch + // (the CREATE/rewrite xact is dirty at its own writes), counted under + // raw_stash_deferred; toast_stash_buffered covers only non-dirty + // marker-proven admissions + assert!(decoder_stats.raw_stash_deferred.load(Ordering::Relaxed) > 0); assert_eq!( decoder_stats.toast_chunks_malformed.load(Ordering::Relaxed), 0 @@ -580,8 +586,8 @@ async fn alter_rewrite_link_swap_retires_old_mirror() { assert_eq!(stats.toast_rewrite_barriers.load(Ordering::Relaxed), 1); assert!(stats.toast_stash_decoded.load(Ordering::Relaxed) > 0); assert!( - stats.toast_stash_skipped.load(Ordering::Relaxed) > 0, - "rewritten main tuples stay fenced" + stats.raw_decode_ordinary_ops.load().iter().sum::() > 0, + "rewritten main tuples decode raw; unmapped rows discard at plan" ); assert_eq!(stats.toast_stash_discarded.load(Ordering::Relaxed), 0); diff --git a/tests/types_sweep.rs b/tests/types_sweep.rs index d4dd2b1d..f7a67217 100644 --- a/tests/types_sweep.rs +++ b/tests/types_sweep.rs @@ -6,13 +6,12 @@ #[path = "common/inproc_harness.rs"] mod fx; -use std::process::{Command, Stdio}; +use fx::spawn_txn; use std::time::Duration; use walshadow::mapping::ColumnMapping; use walshadow::mapping::TableTarget; use walshadow::schema::RelName; -use walshadow::shadow::Shadow; // walsender must clear ch_http by >1 (CH binds interserver = ch_http + 1). const SLOT_BROAD: PortSlot = PortSlot { @@ -67,45 +66,6 @@ fn col(attnum: i16, name: &str, ty: &str) -> ColumnMapping { } } -fn spawn_txn(source: &Shadow, body: &str) -> std::thread::JoinHandle<()> { - let sock = source.config().socket_dir.clone(); - let port = source.config().port; - let sql = body.to_owned(); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(200)); - let mut child = Command::new("psql") - .args([ - "-h", - sock.to_str().unwrap(), - "-p", - &port.to_string(), - "-U", - "postgres", - "-d", - "postgres", - "-v", - "ON_ERROR_STOP=1", - "-f", - "-", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn psql"); - { - use std::io::Write as _; - child - .stdin - .as_mut() - .expect("stdin piped") - .write_all(sql.as_bytes()) - .unwrap(); - } - let _ = child.wait(); - }) -} - fn skip_gate() -> bool { if !fx::pg_available() || !fx::pg_basebackup_available() || !fx::clickhouse_available() { eprintln!("skip: missing initdb / pg_basebackup / clickhouse"); diff --git a/tests/wal_stream_throughput.rs b/tests/wal_stream_throughput.rs index 8083c18c..1ff7267b 100644 --- a/tests/wal_stream_throughput.rs +++ b/tests/wal_stream_throughput.rs @@ -320,9 +320,12 @@ async fn pump_throughput_breakdown() { let owned = Record { parsed: r.parsed.clone().into_owned(), source_lsn: r.source_lsn, + next_lsn: r.next_lsn, page_magic: r.page_magic, route: r.route, - catalog_signal: r.catalog_signal, + catalog_boundary: r.catalog_boundary, + boundary_info: r.boundary_info.clone(), + defer_catalog_decode: r.defer_catalog_decode, }; // Push then immediately pop to drop, so we measure // clone+drop without growing memory unboundedly. diff --git a/tests/weird_identifiers.rs b/tests/weird_identifiers.rs index ddda3bcd..0e587486 100644 --- a/tests/weird_identifiers.rs +++ b/tests/weird_identifiers.rs @@ -5,13 +5,12 @@ #[path = "common/inproc_harness.rs"] mod fx; -use std::process::{Command, Stdio}; +use fx::spawn_txn; use std::time::Duration; use walshadow::mapping::ColumnMapping; use walshadow::mapping::TableTarget; use walshadow::schema::RelName; -use walshadow::shadow::Shadow; // walsender must clear ch_http by >1 (CH binds interserver = ch_http + 1). const SLOT: PortSlot = PortSlot { @@ -107,45 +106,6 @@ fn create_ch_dests(ch: &fx::ChServer) { .expect("create w_cols dest"); } -fn spawn_txn(source: &Shadow, body: &str) -> std::thread::JoinHandle<()> { - let sock = source.config().socket_dir.clone(); - let port = source.config().port; - let sql = body.to_owned(); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(200)); - let mut child = Command::new("psql") - .args([ - "-h", - sock.to_str().unwrap(), - "-p", - &port.to_string(), - "-U", - "postgres", - "-d", - "postgres", - "-v", - "ON_ERROR_STOP=1", - "-f", - "-", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn psql"); - { - use std::io::Write as _; - child - .stdin - .as_mut() - .expect("stdin piped") - .write_all(sql.as_bytes()) - .unwrap(); - } - let _ = child.wait(); - }) -} - fn skip_gate() -> bool { if !fx::pg_available() || !fx::pg_basebackup_available() || !fx::clickhouse_available() { eprintln!("skip: missing initdb / pg_basebackup / clickhouse"); diff --git a/tests/xact_buffer.rs b/tests/xact_buffer.rs index 64739f15..2831329d 100644 --- a/tests/xact_buffer.rs +++ b/tests/xact_buffer.rs @@ -22,8 +22,9 @@ use std::time::Duration; use tokio::sync::Mutex; use walrus::pg::walparser::RelFileNode; use walshadow::ch_emitter::EmitterStats; +use walshadow::desc_log::{BatchRecord, DescLogIdentity, DescriptorLog, LogEntry, LogValue}; use walshadow::heap_decoder::{ - ColumnValue, CommittedTuple, DecodedHeap, DecodedTuple, HeapOp, ToastPointer, + ColumnValue, CommittedTuple, DecodedHeap, DecodedTuple, DescribedHeap, HeapOp, ToastPointer, }; use walshadow::pg::socket_conninfo; use walshadow::shadow::{Shadow, ShadowConfig}; @@ -143,6 +144,18 @@ fn heap( } } +/// Attach descriptor the decoder way: spanned lookup at record LSN +fn described(log: &DescriptorLog, decoded: DecodedHeap) -> DescribedHeap { + let (descriptor, valid_from) = log + .descriptor_at_spanned(decoded.rfn, decoded.source_lsn) + .expect("fixture rfn covered by seeded log"); + DescribedHeap { + decoded, + descriptor, + descriptor_valid_from: valid_from, + } +} + fn cfg(spill_dir: std::path::PathBuf, budget: usize) -> XactBufferConfig { XactBufferConfig { xact_buffer_max: budget, @@ -150,11 +163,55 @@ fn cfg(spill_dir: std::path::PathBuf, budget: usize) -> XactBufferConfig { } } +/// Seed a descriptor log from the shadow catalog's current state: the +/// interval oracle detoast reads (mirrors the daemon's boot seed). +async fn log_from_catalog(cat: &Arc>, dir: &std::path::Path) -> DescriptorLog { + let mut guard = cat.lock().await; + let db_oid = guard.current_database_oid().await.expect("db oid"); + let (_, descs) = guard.fetch_all_descriptors().await.expect("fetch all"); + drop(guard); + let log = DescriptorLog::open( + dir, + DescLogIdentity { + pg_major: 17, + system_id: "test".into(), + timeline: 1, + db_oid, + wal_seg_size: 16 * 1024 * 1024, + }, + ) + .await + .expect("open desc log"); + let entries = descs + .into_iter() + .map(|d| { + Arc::new(LogEntry { + valid_from: 0, + oid: d.oid, + rfn: d.rfn, + value: LogValue::Present(Arc::new(d)), + }) + }) + .collect(); + log.seed( + BatchRecord { + captured_at: 1, + commit_lsn: 0, + observations: Vec::new(), + ambiguities: Vec::new(), + entries, + }, + 1, + ) + .await + .expect("seed desc log"); + log +} + /// Pipeline-path drain: `drain_committed` → `into_walk` → `detoast_heap` /// per heap step, collecting [`CommittedTuple`]s in walk order. async fn drain_all( b: &mut XactBuffer, - cat: &Arc>, resolver: &ToastResolver, xid: u32, commit_ts: i64, @@ -177,9 +234,9 @@ async fn drain_all( let spool = walk.chunks.iter().find_map(|g| g.spool()); for step in walk.steps { if let WalkStep::Heap(mut heap) = step { - detoast_heap(&mut heap, spool, &ref_maps, cat, false, resolver).await?; + detoast_heap(&mut heap, spool, &ref_maps, resolver).await?; out.push(CommittedTuple { - decoded: heap, + decoded: heap.decoded, commit_ts: drain.commit_ts, commit_lsn: drain.commit_lsn, }); @@ -231,16 +288,26 @@ async fn commit_drains_in_arrival_order_and_clears_state() { Some(ColumnValue::Text("x".into())), ] }; - b.on_heap(heap(rfn, 7, 100, HeapOp::Insert, one_col(1))) - .await - .unwrap(); - b.on_heap(heap(rfn, 7, 200, HeapOp::Update, one_col(2))) - .await - .unwrap(); - b.on_heap(heap(rfn, 8, 110, HeapOp::Insert, one_col(3))) - .await - .unwrap(); - let seen = drain_all(&mut b, &cat, &ToastResolver::disabled(), 7, 12345, 300, &[]) + let log = log_from_catalog(&cat, tmp.path()).await; + b.on_heap(described( + &log, + heap(rfn, 7, 100, HeapOp::Insert, one_col(1)), + )) + .await + .unwrap(); + b.on_heap(described( + &log, + heap(rfn, 7, 200, HeapOp::Update, one_col(2)), + )) + .await + .unwrap(); + b.on_heap(described( + &log, + heap(rfn, 8, 110, HeapOp::Insert, one_col(3)), + )) + .await + .unwrap(); + let seen = drain_all(&mut b, &ToastResolver::disabled(), 7, 12345, 300, &[]) .await .unwrap(); assert_eq!(seen.len(), 2); @@ -256,6 +323,87 @@ async fn commit_drains_in_arrival_order_and_clears_state() { assert_eq!(b.stats().drain_lsn, 300); } +/// Admission: a user record flagged `defer_catalog_decode` enters raw +/// spill without touching descriptors; commit resolution yields Ordinary +/// and the payload-less synthetic record fails closed at decode — never a +/// silent skip +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn defer_catalog_decode_stashes_raw_and_commit_fences() { + use walrus::pg::walparser::{ + BlockLocation, RmId, XLogRecord, XLogRecordBlock, XLogRecordBlockHeader, XLogRecordHeader, + }; + use walshadow::record::{Record, RecordSink, Route}; + use walshadow::xact_buffer::{BufferingDecoderSink, resolve_stash}; + + let Some((tmp, shadow, cat, rfn)) = fixture_shadow_with_things(55709).await else { + return; + }; + let _stop = stop_on_drop(&shadow); + let cat = Arc::new(Mutex::new(cat)); + let log = Arc::new(log_from_catalog(&cat, tmp.path()).await); + let buffer = Arc::new(Mutex::new( + XactBuffer::new(cfg(tmp.path().join("spill"), 1024)).unwrap(), + )); + let mut sink = BufferingDecoderSink::new(log.clone(), buffer.clone()); + let record = Record { + parsed: XLogRecord { + header: XLogRecordHeader { + resource_manager_id: RmId::Heap as u8, + xact_id: 77, + total_record_length: 64, + ..Default::default() + }, + blocks: vec![XLogRecordBlock { + header: XLogRecordBlockHeader { + location: BlockLocation { + rel: rfn, + block_no: 0, + }, + ..Default::default() + }, + ..Default::default() + }], + ..Default::default() + }, + source_lsn: 150, + next_lsn: 160, + page_magic: 0xD116, + route: Route::ToDecoder, + catalog_boundary: false, + boundary_info: None, + defer_catalog_decode: true, + }; + sink.on_record(&record).await.unwrap(); + let load = |c: &std::sync::atomic::AtomicU64| c.load(std::sync::atomic::Ordering::Relaxed); + assert_eq!(load(&sink.stats().raw_stash_deferred), 1); + assert_eq!( + sink.stats().raw_stash_dirty_ops.load()[0], + 1, + "insert-op labelled", + ); + assert_eq!(load(&sink.stats().decoded), 0, "no inline decode"); + assert_eq!( + load(&sink.stats().toast_stash_buffered), + 0, + "defer path, not marker path" + ); + let stats = Arc::new(EmitterStats::default()); + resolve_stash(&buffer, &log, 77, &[], 1000, stats.clone()) + .await + .unwrap(); + let mut b = buffer.lock().await; + let err = drain_all(&mut b, &ToastResolver::disabled(), 77, 0, 1000, &[]) + .await + .expect_err("payload-less raw record must fail closed, not skip"); + assert!( + matches!( + &err, + walshadow::xact_buffer::XactBufferError::OrdinaryFailClosed { lsn: 150, .. } + ), + "unexpected drain error: {err:?}", + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn commit_unknown_xid_no_ops() { let Some((tmp, shadow, _cat, _rfn)) = fixture_shadow_with_things(55702).await else { @@ -298,19 +446,26 @@ async fn commit_drains_spilled_then_in_memory_entries() { Some(ColumnValue::Int4(0)), Some(ColumnValue::Text("z".into())), ]; + let log = log_from_catalog(&cat, tmp.path()).await; // Three big tuples first — spill engages after the second. for i in 0..3 { - b.on_heap(heap(rfn, 5, 100 + i, HeapOp::Insert, fat_col.clone())) - .await - .unwrap(); + b.on_heap(described( + &log, + heap(rfn, 5, 100 + i, HeapOp::Insert, fat_col.clone()), + )) + .await + .unwrap(); } // Then small ones that stay in memory. for i in 0..2 { - b.on_heap(heap(rfn, 5, 200 + i, HeapOp::Update, small_col.clone())) - .await - .unwrap(); + b.on_heap(described( + &log, + heap(rfn, 5, 200 + i, HeapOp::Update, small_col.clone()), + )) + .await + .unwrap(); } - let seen = drain_all(&mut b, &cat, &ToastResolver::disabled(), 5, 0, 250, &[]) + let seen = drain_all(&mut b, &ToastResolver::disabled(), 5, 0, 250, &[]) .await .unwrap(); assert_eq!(seen.len(), 5); @@ -347,26 +502,19 @@ async fn commit_merges_top_and_subxact_in_source_lsn_order() { Some(ColumnValue::Text("m".into())), ] }; - b.on_heap(heap(rfn, 7, 100, HeapOp::Insert, col(1))) + let log = log_from_catalog(&cat, tmp.path()).await; + b.on_heap(described(&log, heap(rfn, 7, 100, HeapOp::Insert, col(1)))) .await .unwrap(); - b.on_heap(heap(rfn, 8, 150, HeapOp::Insert, col(2))) + b.on_heap(described(&log, heap(rfn, 8, 150, HeapOp::Insert, col(2)))) .await .unwrap(); - b.on_heap(heap(rfn, 7, 200, HeapOp::Insert, col(3))) + b.on_heap(described(&log, heap(rfn, 7, 200, HeapOp::Insert, col(3)))) + .await + .unwrap(); + let seen = drain_all(&mut b, &ToastResolver::disabled(), 7, 12345, 300, &[8]) .await .unwrap(); - let seen = drain_all( - &mut b, - &cat, - &ToastResolver::disabled(), - 7, - 12345, - 300, - &[8], - ) - .await - .unwrap(); let lsns: Vec = seen.iter().map(|c| c.decoded.source_lsn).collect(); assert_eq!(lsns, vec![100, 150, 200]); // Per-top accounting: one bump, regardless of subxact count. @@ -416,20 +564,16 @@ async fn detoast_concatenates_uncompressed_chunks_into_text() { // this test (no `standby.signal`), so `pg_last_wal_replay_lsn()` // returns NULL and would otherwise time out. Matches the // convention in `tests/shadow_catalog.rs`. - b.on_heap(heap(rfn, 33, 0, HeapOp::Insert, vec![id_col, body_ptr])) - .await - .unwrap(); - let seen = drain_all( - &mut b, - &cat, - &ToastResolver::disabled(), - 33, - 12345, - 300, - &[], - ) + let log = log_from_catalog(&cat, tmp.path()).await; + b.on_heap(described( + &log, + heap(rfn, 33, 0, HeapOp::Insert, vec![id_col, body_ptr]), + )) .await .unwrap(); + let seen = drain_all(&mut b, &ToastResolver::disabled(), 33, 12345, 300, &[]) + .await + .unwrap(); assert_eq!(seen.len(), 1); let body_col = &seen[0].decoded.new.as_ref().unwrap().columns[1]; match body_col { @@ -475,9 +619,13 @@ async fn detoast_missing_chunk_seq_errors_clearly() { } // source_lsn=0 to bypass the shadow-replay gate; see sibling test // for the rationale. - b.on_heap(heap(rfn, 42, 0, HeapOp::Insert, vec![id_col, body_ptr])) - .await - .unwrap(); + let log = log_from_catalog(&cat, tmp.path()).await; + b.on_heap(described( + &log, + heap(rfn, 42, 0, HeapOp::Insert, vec![id_col, body_ptr]), + )) + .await + .unwrap(); // In-xact gap stays a hard error even with an active store (disabled // mode NULL-fills): the value's key is present in the xact's chunk map, // so the gap is a decode bug, never a merge-collapsed store miss @@ -485,7 +633,7 @@ async fn detoast_missing_chunk_seq_errors_clearly() { Arc::new(MemChunkStore::new()), Arc::new(EmitterStats::default()), ); - let err = drain_all(&mut b, &cat, &resolver, 42, 0, 200, &[]) + let err = drain_all(&mut b, &resolver, 42, 0, 200, &[]) .await .expect_err("missing chunk surfaces"); match err { @@ -504,10 +652,12 @@ async fn abort_drops_xact_and_unlinks_spill_against_real_shadow() { // Same shape as the unit test, but reachable via the production // catalog handle so the integration suite covers the bin's // dispatch chain in one place. - let Some((tmp, shadow, _cat, rfn)) = fixture_shadow_with_things(55707).await else { + let Some((tmp, shadow, cat, rfn)) = fixture_shadow_with_things(55707).await else { return; }; let _stop = stop_on_drop(&shadow); + let cat = Arc::new(Mutex::new(cat)); + let log = log_from_catalog(&cat, tmp.path()).await; let spill_dir = tmp.path().join("spill"); let mut b = XactBuffer::new(cfg(spill_dir.clone(), 1024)).unwrap(); let fat_col = vec![ @@ -515,9 +665,12 @@ async fn abort_drops_xact_and_unlinks_spill_against_real_shadow() { Some(ColumnValue::Bytea(vec![0u8; 256])), ]; for i in 0..10 { - b.on_heap(heap(rfn, 11, 100 + i, HeapOp::Insert, fat_col.clone())) - .await - .unwrap(); + b.on_heap(described( + &log, + heap(rfn, 11, 100 + i, HeapOp::Insert, fat_col.clone()), + )) + .await + .unwrap(); } assert!(b.stats().spill_xacts_active >= 1); b.abort(11, 200, &[]).await.unwrap();