From cf94753301aafbca588c15205e83012d231af033 Mon Sep 17 00:00:00 2001 From: serprex <159546+serprex@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:26:09 +0000 Subject: [PATCH 1/4] Track next_lsn as EndRecPtr. Synchronize on catalog updating commits This will be basis for keeping some time series tracking of catalog updates, in meantime this allows exact comparisons with pg_last_wal_replay_lsn Also enforce shadow catalog is using streaming replication, avoids deadlocks --- plans/source.md | 59 +++- src/backfill/backup_backfill.rs | 3 +- src/bin/stream.rs | 106 +++++-- src/filter/catalog_tracker.rs | 60 ++-- src/filter/engine.rs | 220 +++++++++++++- src/lib.rs | 3 +- src/ops/metrics.rs | 27 ++ src/record.rs | 10 + src/source/boundary_hold.rs | 449 +++++++++++++++++++++++++++++ src/source/mod.rs | 1 + src/source/queueing_record_sink.rs | 37 ++- src/source/shadow_stream.rs | 20 ++ src/source/wal_stream.rs | 355 ++++++++++++++++++++++- tests/bin_stream_e2e.rs | 11 + tests/multi_segment_filter.rs | 2 + tests/wal_stream_throughput.rs | 2 + 16 files changed, 1283 insertions(+), 82 deletions(-) create mode 100644 src/source/boundary_hold.rs diff --git a/plans/source.md b/plans/source.md index 38742757..d858550b 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 future +catalog capture samples shadow at ([future/xact_stash.md](future/xact_stash.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 @@ -378,7 +431,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/src/backfill/backup_backfill.rs b/src/backfill/backup_backfill.rs index d5306226..07ae0661 100644 --- a/src/backfill/backup_backfill.rs +++ b/src/backfill/backup_backfill.rs @@ -1154,9 +1154,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/bin/stream.rs b/src/bin/stream.rs index 06e624ed..6a209428 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 @@ -589,12 +604,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!( @@ -1396,6 +1427,15 @@ 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 decoder_xact = BoundaryHoldSink::new(decoder_xact, boundary_gate); let mut record_sink = DaemonSinks { metrics: MetricsRecordSink::default(), decoder_xact, @@ -1454,36 +1494,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 { @@ -1701,6 +1736,7 @@ async fn run_session( active_connections: shadow_agg.active_connections as u64, dropped_total: shadow_agg.dropped_total, }, + &boundary_hold_stats, metrics_resolver.as_deref(), metrics_backfiller.as_deref(), ) @@ -2249,6 +2285,7 @@ async fn populate_metrics( oracle_stats: Option<&walshadow::oracle::OracleStats>, uptime_secs: u64, shadow_view: ShadowMetricsView, + boundary_hold: &BoundaryHoldStats, config_resolver: Option<&ConfigResolver>, backfiller: Option<&walshadow::copy_backfill::CopyBackfiller>, ) { @@ -2381,6 +2418,9 @@ 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(), 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), @@ -3397,6 +3437,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/filter/catalog_tracker.rs b/src/filter/catalog_tracker.rs index d04b93bb..cfc61b4b 100644 --- a/src/filter/catalog_tracker.rs +++ b/src/filter/catalog_tracker.rs @@ -393,6 +393,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 +431,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, diff --git a/src/filter/engine.rs b/src/filter/engine.rs index a73e8944..df320c48 100644 --- a/src/filter/engine.rs +++ b/src/filter/engine.rs @@ -10,8 +10,14 @@ //! `CatalogTracker`. Unrecognised → ToShadow: correctness over bytes, //! wrongly suppressing a catalog record breaks shadow. -use walrus::pg::walparser::{XLogRecord, XLogRecordBlock}; +use std::collections::HashSet; +use walrus::pg::walparser::{RmId, XLogRecord, XLogRecordBlock}; + +use crate::decode::wal_xact::{ + XLOG_XACT_ABORT, XLOG_XACT_ABORT_PREPARED, XLOG_XACT_COMMIT, XLOG_XACT_COMMIT_PREPARED, + XLOG_XACT_OPMASK, parse_xact_payload, +}; use crate::filter::catalog_tracker::{CatalogTracker, CatalogTrackerStats}; use crate::filter::classify::{Class, classify}; use crate::filter::main_data; @@ -91,12 +97,27 @@ pub(crate) struct FilterSnapshot { catalog: CatalogTrackerStats, } +/// Full routing verdict for one record; see [`Filter::decide_record`]. +#[derive(Debug, Clone, Copy)] +pub struct Verdict { + pub route: Route, + pub signal: CatalogSignal, + /// 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, +} + /// 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, + /// Xids (top or sub) that wrote a catalog-touching record; drained at + /// their commit / abort. Crash-orphaned xids linger, bounded by + /// workload (no commit ever arrives to hold on) + catalog_dirty_xids: HashSet, } impl Filter { @@ -104,11 +125,12 @@ impl Filter { Self { tracker: CatalogTracker::new(), stats: FilterStats::default(), + catalog_dirty_xids: HashSet::new(), } } pub fn decide(&mut self, record: &XLogRecord) -> Route { - self.decide_with_signal(record).0 + self.decide_record(record).route } pub fn tracker(&self) -> &CatalogTracker { @@ -137,38 +159,88 @@ 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 - /// pump-position bump would be consumable before pre-DDL records finish - /// decoding; see `catalog_tracker` module doc) + /// [`Self::decide_record`] narrowed to `(route, signal)`. pub fn decide_with_signal(&mut self, record: &XLogRecord) -> (Route, CatalogSignal) { + let v = self.decide_record(record); + (v.route, v.signal) + } + + /// 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), plus the + /// catalog-boundary verdict driving the pump's publication hold. + pub fn decide_record(&mut self, record: &XLogRecord) -> Verdict { let signal = 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, true), + // Relmap update (VACUUM FULL mapped catalog) is Special-class + Class::Special => (Route::ToShadow, signal != CatalogSignal::None), Class::User => { if any_block_is_catalog(&self.tracker, &record.blocks) { // tracker has filenodes the bootstrap classify rule misses - Route::ToShadow + (Route::ToShadow, true) } 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 }, }; + let xid = record.header.xact_id; + if catalog_touch && xid != 0 { + self.catalog_dirty_xids.insert(xid); + } + let catalog_boundary = self.observe_xact_end(record); self.stats .record(class, route, record.header.total_record_length as u64); - (route, signal) + Verdict { + route, + signal, + catalog_boundary, + } + } + + /// 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`), so no ASSIGNMENT tracking is needed. + fn observe_xact_end(&mut self, record: &XLogRecord) -> bool { + if record.header.resource_manager_id != RmId::Xact as u8 + || self.catalog_dirty_xids.is_empty() + { + return false; + } + let info = record.header.info; + let op = info & XLOG_XACT_OPMASK; + 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 false; + } + let payload = parse_xact_payload(info, &record.main_data); + let mut hit = self.catalog_dirty_xids.remove(&record.header.xact_id); + if let Some(x) = payload.twophase_xid { + hit |= self.catalog_dirty_xids.remove(&x); + } + for x in &payload.subxacts { + hit |= self.catalog_dirty_xids.remove(x); + } + hit && is_commit } pub fn rmgr_label(record: &XLogRecord) -> String { @@ -328,4 +400,122 @@ 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 / twophase sections. + fn xact_end(op: u8, xid: u32, subxacts: &[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() { + info |= XLOG_XACT_HAS_INFO; + let mut xinfo = 0u32; + if !subxacts.is_empty() { + xinfo |= 1 << 1; // XACT_XINFO_HAS_SUBXACTS + } + 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 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 + } + + 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)); + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 20000)], 8)); + // DML-only xid 8 commit: never parks + let v = f.decide_record(&xact_end(XLOG_XACT_COMMIT, 8, &[], None)); + assert!(!v.catalog_boundary); + // Catalog-dirty xid 7 commit: boundary, drained after + let v = f.decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None)); + assert!(v.catalog_boundary); + let v = f.decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None)); + 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)); + let v = f.decide_record(&xact_end(XLOG_XACT_ABORT, 7, &[], None)); + assert!(!v.catalog_boundary, "rolled-back DDL never holds"); + let v = f.decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None)); + 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)); + let v = f.decide_record(&xact_end(XLOG_XACT_COMMIT, 100, &[101, 102], None)); + assert!(v.catalog_boundary); + } + + #[test] + fn commit_prepared_matches_prepared_xid() { + let mut f = Filter::new(); + f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 1259)], 300)); + // COMMIT PREPARED: header xid differs, prepared xid in payload + let v = f.decide_record(&xact_end(XLOG_XACT_COMMIT_PREPARED, 0, &[], Some(300))); + 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); + let v = f.decide_record(&xact_end(XLOG_XACT_COMMIT, 9, &[], None)); + 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).route, Route::ToShadow); + let v = f.decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None)); + 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)); + let v = f.decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None)); + assert!(v.catalog_boundary); + } } diff --git a/src/lib.rs b/src/lib.rs index fa03c165..81676844 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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, manifest, queueing_record_sink, segment_sink, shadow_stream, source_feed, + wal_stream, }; #[doc(hidden)] pub use toast::toast_retire; diff --git a/src/ops/metrics.rs b/src/ops/metrics.rs index 2e3ee32d..9044013f 100644 --- a/src/ops/metrics.rs +++ b/src/ops/metrics.rs @@ -134,6 +134,12 @@ 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, } #[derive(Debug, Clone, Default)] @@ -601,6 +607,18 @@ 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_config_pending_decl_rels", "Forward-declared per-table opt-ins awaiting their CREATE TABLE.", @@ -658,6 +676,15 @@ 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(); + // Process CPU as a float counter (seconds); rate() ≈ cores in use. let name = "walshadow_process_cpu_seconds_total"; writeln!( diff --git a/src/record.rs b/src/record.rs index 59bf8729..99906dfb 100644 --- a/src/record.rs +++ b/src/record.rs @@ -74,9 +74,17 @@ 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, } pub trait RecordSink { @@ -160,9 +168,11 @@ 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, }); Ok(()) }) diff --git a/src/source/boundary_hold.rs b/src/source/boundary_hold.rs new file mode 100644 index 00000000..a5b3bb94 --- /dev/null +++ b/src/source/boundary_hold.rs @@ -0,0 +1,449 @@ +//! 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::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. Forwards every record, +/// then at a catalog boundary force-flushes the pump-side batch (the commit +/// must not strand in the accumulator while the pump parks — and the flush +/// surfaces any parked worker error first) and parks in +/// [`CatalogBoundaryGate::hold`]. +pub struct BoundaryHoldSink { + pub inner: QueueingRecordSink, + pub gate: CatalogBoundaryGate, +} + +impl BoundaryHoldSink { + pub fn new(inner: QueueingRecordSink, gate: CatalogBoundaryGate) -> Self { + Self { inner, gate } + } + + 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 { + self.inner.on_record(record).await?; + if !record.catalog_boundary { + return Ok(()); + } + 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); + } + Ok(()) + }) + } + + 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); + let rec = Record { + source_lsn: 0x1F00, + next_lsn: 0x2000, + catalog_boundary: true, + ..Default::default() + }; + // Worker fails on the shipped commit; hold's worker_alive check + // wakes with Err and the parked root cause wins + let err = sink.on_record(&rec).await.expect_err("must fail"); + assert!(err.to_string().contains("boom"), "{err}"); + } +} diff --git a/src/source/mod.rs b/src/source/mod.rs index a6556562..546333af 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -1,3 +1,4 @@ +pub mod boundary_hold; 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..60ada449 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,11 @@ 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, }); if self.buf.len() >= self.batch_size { self.flush_buf().await?; @@ -357,9 +366,8 @@ mod tests { Record { parsed: XLogRecord::default(), source_lsn, - page_magic: 0, route: crate::record::Route::ToShadow, - catalog_signal: crate::record::CatalogSignal::None, + ..Default::default() } } @@ -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..fefdd105 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}; @@ -238,8 +240,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 +251,12 @@ impl WalStream { offset: start_offset, source, })?; - (route, catalog_signal) = self.filter.decide_with_signal(&parsed); + verdict = self.filter.decide_record(&parsed); // `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 +306,15 @@ 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_signal: verdict.signal, + catalog_boundary: verdict.catalog_boundary, }; if let Some(sink) = record_sink.as_deref_mut() { sink.on_record(&record).await?; @@ -422,6 +427,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; @@ -904,6 +931,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_xact_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/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/multi_segment_filter.rs b/tests/multi_segment_filter.rs index 211c0e92..6e3a7c02 100644 --- a/tests/multi_segment_filter.rs +++ b/tests/multi_segment_filter.rs @@ -322,9 +322,11 @@ 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, }); Ok(()) }) diff --git a/tests/wal_stream_throughput.rs b/tests/wal_stream_throughput.rs index 8083c18c..a7c70304 100644 --- a/tests/wal_stream_throughput.rs +++ b/tests/wal_stream_throughput.rs @@ -320,9 +320,11 @@ 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, }; // Push then immediately pop to drop, so we measure // clone+drop without growing memory unboundedly. From 3bbc01ed1c2d4016404d341bed8e215e97e9fbb2 Mon Sep 17 00:00:00 2001 From: serprex <159546+serprex@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:55:07 +0000 Subject: [PATCH 2/4] Make catalog decoding consistent across DDL & restarts Replace live catalog cache & invalidation tracking with durable relation descriptor log Log checkpoints at commit boundaries. This allows looking up relation id at a given LSN --- plans/GLOSSARY.md | 45 +- plans/INDEX.md | 6 +- plans/TOAST.md | 23 +- plans/decoder.md | 3 +- plans/desc_log.md | 151 ++ plans/emitter.md | 74 +- plans/filter.md | 42 +- plans/future/INDEX.md | 3 +- plans/future/TABLESPACES.md | 123 +- plans/future/catalog_capture_completeness.md | 83 + plans/future/xact_stash.md | 6 + plans/overview.md | 19 +- plans/shadow.md | 122 +- plans/source.md | 5 +- plans/xact.md | 60 +- src/backfill/backfill_bootstrap.rs | 61 +- src/backfill/backfill_types.rs | 2 + src/backfill/backup_backfill.rs | 40 +- src/backfill/backup_page_walk.rs | 1 + src/backfill/copy_backfill.rs | 9 +- src/bin/stream.rs | 203 ++- src/catalog/desc_log.rs | 1705 ++++++++++++++++++ src/catalog/mod.rs | 1 + src/catalog/shadow_catalog.rs | 1070 +++-------- src/config.rs | 70 +- src/decode/heap_decoder.rs | 1 + src/decode/wal_xact.rs | 193 +- src/emit/ch_ddl.rs | 78 +- src/emit/ch_emitter.rs | 1 + src/emit/pipeline/batcher.rs | 1 + src/emit/pipeline/bootstrap.rs | 3 + src/emit/pipeline/decode.rs | 71 +- src/emit/pipeline/mod.rs | 17 +- src/emit/pipeline/reorder.rs | 181 +- src/filter/catalog_tracker.rs | 232 +-- src/filter/engine.rs | 752 +++++++- src/lib.rs | 6 +- src/ops/metrics.rs | 142 ++ src/pg.rs | 168 +- src/record.rs | 37 +- src/runtime_config.rs | 1 + src/schema.rs | 133 +- src/source/boundary_hold.rs | 53 +- src/source/catalog_capture.rs | 329 ++++ src/source/manifest.rs | 5 +- src/source/mod.rs | 1 + src/source/queueing_record_sink.rs | 23 +- src/source/wal_stream.rs | 26 +- src/xact/xact_buffer.rs | 426 ++--- tests/bootstrap_pipeline_ch.rs | 1 + tests/common/inproc_harness.rs | 121 +- tests/desc_log_e2e.rs | 286 +++ tests/desc_log_restart_e2e.rs | 381 ++++ tests/emitter_budget_flush.rs | 1 + tests/emitter_native_types.rs | 1 + tests/emitter_tls.rs | 1 + tests/multi_segment_filter.rs | 2 +- tests/shadow_catalog.rs | 702 ++----- tests/wal_stream_throughput.rs | 2 +- tests/xact_buffer.rs | 62 +- 60 files changed, 5709 insertions(+), 2658 deletions(-) create mode 100644 plans/desc_log.md create mode 100644 plans/future/catalog_capture_completeness.md create mode 100644 src/catalog/desc_log.rs create mode 100644 src/source/catalog_capture.rs create mode 100644 tests/desc_log_e2e.rs create mode 100644 tests/desc_log_restart_e2e.rs diff --git a/plans/GLOSSARY.md b/plans/GLOSSARY.md index 0066654d..717d98c1 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 @@ -256,11 +258,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)) @@ -379,11 +380,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 +436,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 +483,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 @@ -554,11 +556,12 @@ 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, +whose filenode is unresolvable 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)) +resolved at commit against the descriptor log at the commit's +`next_lsn` and decoded in the drain merge; tombstoned/uncovered → +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 diff --git a/plans/INDEX.md b/plans/INDEX.md index e9491dbc..72519451 100644 --- a/plans/INDEX.md +++ b/plans/INDEX.md @@ -15,8 +15,10 @@ Cross-doc terminology is collected in [GLOSSARY.md](GLOSSARY.md) `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, 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 diff --git a/plans/TOAST.md b/plans/TOAST.md index 51da5a89..596f931c 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 @@ -207,9 +206,9 @@ logically-logged rels, never toast): 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 + (`toast_stash_skipped`); lifting the fence is + [future/xact_stash.md](future/xact_stash.md) scope. 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/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..d5207424 --- /dev/null +++ b/plans/desc_log.md @@ -0,0 +1,151 @@ +# 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. + +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 | Dropped | Retired | NotCovered | ForeignDb`: + +- worker buffering: Present decodes; ForeignDb and horizon/xid-0 + NotCovered are counted skips; NotCovered/Dropped with a live xid stash + for commit-time resolution; Retired skips (rows can't outlive the + rotation) +- stash resolution at commit `next_lsn`: Present toast → chunk decode + behind its marker barrier, Present ordinary → fenced (stash item 5, + [future/xact_stash.md](future/xact_stash.md)), tombstones discard +- decode pool: per-job memo over the log (mapping writes land inside the + barrier fence, between jobs); anything but Present on a drained record + is fatal except ForeignDb / pre-horizon skips +- 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 after each manifest persist against the same resolved floor +([ops.md](ops.md)): 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. 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..97724c47 100644 --- a/plans/emitter.md +++ b/plans/emitter.md @@ -46,9 +46,9 @@ 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`, +- COMMIT — stash resolution against the descriptor log at the commit's + `next_lsn`, `XactBuffer::drain_committed` under the drain xid + (prepared xid for COMMIT PREPARED), 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 @@ -411,11 +411,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 +447,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) diff --git a/plans/filter.md b/plans/filter.md index 916f378f..22566d22 100644 --- a/plans/filter.md +++ b/plans/filter.md @@ -75,17 +75,37 @@ 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 + +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..671eb288 100644 --- a/plans/future/INDEX.md +++ b/plans/future/INDEX.md @@ -3,7 +3,8 @@ 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 * [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 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..1940eb82 --- /dev/null +++ b/plans/future/catalog_capture_completeness.md @@ -0,0 +1,83 @@ +# 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 + +- **`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/xact_stash.md b/plans/future/xact_stash.md index 7f8321fd..438182e3 100644 --- a/plans/future/xact_stash.md +++ b/plans/future/xact_stash.md @@ -330,6 +330,12 @@ archive-only ## Durable metadata journal +The descriptor log ([../desc_log.md](../desc_log.md)) is this journal: +capture-time descriptor batches, identity-bound header, replay-from-log +determinism. The stash fence lift consumes it via +`descriptor_at(rfn, commit.next_lsn)` instead of building a parallel +store. Original requirements, all satisfied there: + Store one append-only journal beside cursor state. Journal header binds data to: - source system identifier diff --git a/plans/overview.md b/plans/overview.md index ae823d0c..c858fef2 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` @@ -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 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 d858550b..dc74a817 100644 --- a/plans/source.md +++ b/plans/source.md @@ -318,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: diff --git a/plans/xact.md b/plans/xact.md index 14a17a7c..9829a2f6 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) @@ -108,19 +107,20 @@ 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 +Records on a filenode the descriptor log cannot resolve at record time +(the creating xact's boundary hasn't captured yet: 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 lookup +is skipped — the xact's own records can never resolve. 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), installs per-filenode verdicts the drain merge +consumes (toast → decode like live chunks, ordinary heap → fenced skip, +tombstoned/uncovered → 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) @@ -157,9 +157,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 @@ -270,14 +276,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/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_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 07ae0661..ab01eaf3 100644 --- a/src/backfill/backup_backfill.rs +++ b/src/backfill/backup_backfill.rs @@ -61,7 +61,6 @@ 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::decode::heap_decoder::{CommittedTuple, XLOG_HEAP_OPMASK, XLOG_HEAP_TRUNCATE}; use crate::decode::visibility::{ PgMultiXactAccum, PgXactAccum, PgXactPatch, PgXactView, Visibility, tuple_visibility, @@ -692,11 +691,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,9 +844,9 @@ 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, @@ -880,7 +883,7 @@ async fn replay_gap( struct ReplaySink { decoder: BufferingDecoderSink, buffer: Arc>, - catalog: Arc>, + log: Arc, subxact_tracker: SubxactTracker, resolver: ToastResolver, filter_rfns: HashSet<(Oid, Oid)>, @@ -909,15 +912,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 @@ -1005,16 +1009,10 @@ 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 value_permit = + detoast_heap(&mut heap, spool, &ref_maps, &self.log, &self.resolver) + .await + .map_err(SinkError::from)?; let Some(mapping) = crate::emit::pipeline::lookup_mapping( &self.mapping, &rel.rel_name, @@ -1082,7 +1080,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 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..11b46c79 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(), @@ -1245,6 +1249,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 6a209428..5c3633cb 100644 --- a/src/bin/stream.rs +++ b/src/bin/stream.rs @@ -957,21 +957,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) @@ -1010,26 +995,6 @@ 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. @@ -1101,9 +1066,111 @@ 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, + 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() { @@ -1111,13 +1178,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()) @@ -1180,7 +1241,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()); @@ -1227,7 +1287,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()); @@ -1257,6 +1316,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()), @@ -1366,8 +1426,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(), @@ -1399,8 +1458,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, @@ -1418,6 +1476,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, @@ -1435,7 +1497,14 @@ async fn run_session( }, ); let boundary_hold_stats = boundary_gate.stats.clone(); - let decoder_xact = BoundaryHoldSink::new(decoder_xact, boundary_gate); + 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, @@ -1610,6 +1679,11 @@ 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 + desc_log + .maybe_gc(cur.floor.0) + .await + .context("descriptor log gc")?; } // flush caps physical slot's restart_lsn. // Manifest writes are cadence-gated above while keepalive replies inside @@ -1737,6 +1811,8 @@ async fn run_session( dropped_total: shadow_agg.dropped_total, }, &boundary_hold_stats, + &capture_stats, + &desc_log, metrics_resolver.as_deref(), metrics_backfiller.as_deref(), ) @@ -2286,12 +2362,16 @@ async fn populate_metrics( 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 = ( @@ -2421,6 +2501,25 @@ async fn populate_metrics( 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), + 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_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), diff --git a/src/catalog/desc_log.rs b/src/catalog/desc_log.rs new file mode 100644 index 00000000..413f0363 --- /dev/null +++ b/src/catalog/desc_log.rs @@ -0,0 +1,1705 @@ +//! 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. +//! +//! 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 (closed by the ordinary-heap stash fence lift, not +//! here): same-xact DDL+DML on an already-visible rfn stays timing-dependent +//! exactly as with the live-oracle path. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::SeekFrom; +use std::path::{Path, PathBuf}; +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"; +const VERSION: u16 = 1; +/// 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, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct BatchRecord { + /// Boundary commit EndRecPtr (or seed LSN); the replay-from-log key + pub captured_at: u64, + /// Empty = stub: boundary produced no shape change, recorded so boot + /// replay distinguishes "captured, nothing changed" from "never captured" + pub entries: Vec>, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum LookupResult { + Present(Arc), + Dropped, + Retired, + 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>, + batches: BTreeMap>, + covered_through: u64, + floor_at_write: u64, + entries_total: usize, +} + +impl Index { + fn insert_batch(&mut self, batch: Arc) { + 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_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, + 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(), + }), + stats: Arc::new(DescLogStats::default()), + }) + } + + 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 { + 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 LookupResult::ForeignDb; + } + let idx = self.index.read().unwrap(); + let result = lookup(idx.by_rfn.get(&rfn), lsn); + self.count(&result); + result + } + + pub fn descriptor_by_oid_at(&self, oid: Oid, lsn: u64) -> LookupResult { + let idx = self.index.read().unwrap(); + let result = lookup(idx.by_oid.get(&oid), lsn); + self.count(&result); + result + } + + 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::NotCovered => self.stats.lookups_not_covered.fetch_add(1, Relaxed), + LookupResult::ForeignDb => self.stats.lookups_foreign_db.fetch_add(1, Relaxed), + }; + } + + /// 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. + 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) + 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()) + } + + /// 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 + pub fn active_present_at(&self, lsn: u64) -> Vec> { + let idx = self.index.read().unwrap(); + idx.by_oid + .values() + .filter_map(|chain| match lookup(Some(chain), lsn) { + LookupResult::Present(d) => Some(d), + _ => None, + }) + .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", + batch.captured_at, + ), + }); + } + } + 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.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 (retained, dropped_entries) = { + let idx = self.index.read().unwrap(); + let retained = compute_retained(&idx, floor); + let dropped = idx.entries_total - retained.entries_total; + (retained, dropped) + }; + let tail_bytes = w.tail_len - w.header_len; + if dropped_entries < GC_DEAD_ENTRIES && tail_bytes < GC_TAIL_BYTES { + return Ok(false); + } + 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.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 + 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); + let tail = self + .writer + .try_lock() + .map(|w| w.tail_len - w.header_len) + .unwrap_or(0); + (entries, tail, batches) + } +} + +fn lookup(chain: Option<&Vec>, lsn: u64) -> LookupResult { + let Some(chain) = chain else { + return 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 LookupResult::NotCovered; + }; + match &slot.entry.value { + LogValue::Present(d) => LookupResult::Present(d.clone()), + LogValue::Dropped => LookupResult::Dropped, + LogValue::Retired => LookupResult::Retired, + } +} + +fn compute_retained(idx: &Index, floor: u64) -> Index { + // Entry identity by allocation: each Arc is inserted once and + // shared between its batch and both chains + 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)); + } + } + 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(); + if !entries.is_empty() { + out.insert_batch(Arc::new(BatchRecord { + captured_at, + 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_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), + } + } + 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()); + } +} + +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(_) => { + return Err(DescLogError::Corrupt { + file, + offset: frame_start as u64, + detail: format!( + "batch at captured_at {:#x} diverges from earlier copy", + batch.captured_at, + ), + }); + } + 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 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, + })); + } + Ok(BatchRecord { + captured_at, + 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, + entries, + } + } + + 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:?}"), + } + } + + #[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); + } +} diff --git a/src/catalog/mod.rs b/src/catalog/mod.rs index ab7bae7e..4c8ecd75 100644 --- a/src/catalog/mod.rs +++ b/src/catalog/mod.rs @@ -1,3 +1,4 @@ +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..c6301571 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,13 +285,12 @@ 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 + /// out even when TOML-mapped, republish. In-flight /// rows already dispatched still drain; further rows drop at /// `lookup_mapping`. pub async fn exclude_table(&self, rel: &RelName) { @@ -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; } @@ -432,10 +418,8 @@ impl ConfigResolver { ); self.rejections.store(rejections, Ordering::Relaxed); *self.mapping.write().await = resolved.tables.clone(); - // Decode workers cache rfn→(descriptor, mapping) per epoch, cache // 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)); } @@ -664,11 +648,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 { + Arc::new(tokio::sync::RwLock::new(HashMap::new())) } #[test] @@ -914,6 +895,7 @@ mod tests { rel_node: 30000, }, oid: 30000, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new(namespace, name), kind: 'r', @@ -973,16 +955,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 +974,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 +986,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 +1009,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 +1048,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 +1089,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 +1225,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 +1273,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 +1294,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 +1325,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/heap_decoder.rs b/src/decode/heap_decoder.rs index 596bb991..7f1e406f 100644 --- a/src/decode/heap_decoder.rs +++ b/src/decode/heap_decoder.rs @@ -1311,6 +1311,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..1a3bcfb2 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,13 +20,73 @@ 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); + pub(crate) fn parse_xact_assignment(mut data: &[u8]) -> Option<(u32, Vec)> { let top = take_u32(&mut data)?; let count = take_count(&mut data)?; @@ -34,58 +97,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..fce18795 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,14 @@ 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()); } } @@ -509,8 +498,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; } } @@ -519,7 +507,6 @@ impl DdlApplicator { r.forget_derived_mapping(rel).await; } else { self.mapping.write().await.remove(rel); - bump_mapping_epoch(self.invalidation_epoch.as_ref()); } } @@ -567,35 +554,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) = 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 @@ -810,6 +778,7 @@ mod tests { rel_node: 16400, }, oid: 16400, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", name), kind: 'r', @@ -1115,7 +1084,6 @@ mod tests { .into_iter() .collect(); let handle: MappingHandle = Arc::new(tokio::sync::RwLock::new(map)); - let epoch = Arc::new(AtomicU64::new(7)); let new = desc( "orders", vec![ @@ -1134,9 +1102,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 +1111,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..fd694162 100644 --- a/src/emit/ch_emitter.rs +++ b/src/emit/ch_emitter.rs @@ -1730,6 +1730,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/pipeline/batcher.rs b/src/emit/pipeline/batcher.rs index 8f233c9c..4bed80e5 100644 --- a/src/emit/pipeline/batcher.rs +++ b/src/emit/pipeline/batcher.rs @@ -426,6 +426,7 @@ mod tests { rel_node: 16385, }, oid: 16385, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", "t"), kind: 'r', diff --git a/src/emit/pipeline/bootstrap.rs b/src/emit/pipeline/bootstrap.rs index e98fe7f1..398700cf 100644 --- a/src/emit/pipeline/bootstrap.rs +++ b/src/emit/pipeline/bootstrap.rs @@ -354,6 +354,7 @@ mod tests { rel_node, }, oid: rel_node, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", &name), kind: 'r', @@ -414,6 +415,7 @@ mod tests { rel_node, }, oid: rel_node, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", &name), kind: 'r', @@ -459,6 +461,7 @@ mod tests { rel_node, }, oid: rel_node, + toast_oid: 0, namespace_oid: 99, rel_name: RelName::new("pg_toast", &name), kind: 't', diff --git a/src/emit/pipeline/decode.rs b/src/emit/pipeline/decode.rs index 09720949..9a0f0c94 100644 --- a/src/emit/pipeline/decode.rs +++ b/src/emit/pipeline/decode.rs @@ -15,10 +15,13 @@ 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 walrus::pg::walparser::RelFileNode; -use crate::catalog::shadow_catalog::{CatalogError, ShadowCatalog}; +use std::collections::HashMap; + +use crate::catalog::desc_log::{DescriptorLog, LookupResult}; use crate::decode::heap_decoder::{CommittedTuple, DecodedHeap}; use crate::emit::ch_emitter::EmitterStats; use crate::emit::pipeline::Fatal; @@ -28,7 +31,7 @@ use crate::mapping::{MappingHandle, TableMapping}; use crate::ops::oracle::{Oracle, maybe_validate_tuple, resolve_pending_tuple}; use crate::schema::RelDescriptor; 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 @@ -51,7 +54,7 @@ pub struct DecodeJob { /// needs to turn heaps into routed rows. #[derive(Clone)] pub struct DecodeCtx { - pub catalog: Arc>, + pub log: Arc, pub mapping: MappingHandle, pub oracle: Option>, /// Shared FIFO `BatcherMsg` channel: a chunk enqueues as one ordered item @@ -102,9 +105,10 @@ pub async fn decode_and_route( heaps: Vec, chunks: Vec>, permit: Option>, - cache: &mut RelCache<(Arc, Arc)>, ) -> Result { - cache.refresh(); + // Per-job memo: mapping mutations apply inside the barrier fence, + // between jobs, so nothing invalidates within one job + let mut memo: HashMap, Arc)> = HashMap::new(); 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()); @@ -112,39 +116,40 @@ pub async fn decode_and_route( 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) { + let value_permit = detoast_heap(&mut heap, spool, &ref_maps, &ctx.log, &ctx.resolver) + .await + .map_err(|e| e.to_string())? + .map(Arc::new); + // Memo hit: no mapping read. Skip/error arms are never memoised, so + // `foreign_db_rows_skipped`/`unsupported_relations` count per row. + let (rel, mapping) = match memo.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, + let rel = match ctx.log.descriptor_at(heap.rfn, heap.source_lsn) { + LookupResult::Present(rel) => rel, // Physical WAL carries the whole cluster; skip foreign-DB rows - Err(CatalogError::ForeignDatabase(_)) => { + LookupResult::ForeignDb => { ctx.stats .foreign_db_rows_skipped .fetch_add(1, Ordering::Relaxed); continue; } - Err(e) => return Err(e.to_string()), + // Rel died before the coverage horizon: its end state is + // already in CH / backfill, nothing to route + LookupResult::NotCovered if heap.source_lsn <= ctx.log.covered_through() => { + ctx.stats + .foreign_db_rows_skipped + .fetch_add(1, Ordering::Relaxed); + continue; + } + // Drained records must have coverage — anything else is + // a log bug; a silent skip would shed rows invisibly + other => { + return Err(format!( + "descriptor for {:?} at {:#X} not covered: {other:?}", + heap.rfn, heap.source_lsn, + )); + } }; let Some(mapping) = crate::emit::pipeline::lookup_mapping(&ctx.mapping, &rel.rel_name, &ctx.stats) @@ -213,9 +218,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 +229,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..2ccf874d 100644 --- a/src/emit/pipeline/mod.rs +++ b/src/emit/pipeline/mod.rs @@ -32,7 +32,7 @@ use crate::emit::ch_emitter::{EmitterConfig, EmitterStats}; use crate::mapping::{MappingHandle, TableMapping}; use crate::ops::oracle::Oracle; use crate::ops::trace::TxnSpanRegistry; -use crate::schema::{RelName, SchemaEventRx}; +use crate::schema::RelName; use crate::xact::xact_buffer::{SubxactTracker, XactBuffer}; /// One-shot fatal-error signal shared across pipeline stages. Pump polls @@ -130,10 +130,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 +207,7 @@ impl PipelineConfig { tail, buffer, subxact_tracker, - schema_events, - pending_sweeps, + log, stats, span_registry, config_resolver, @@ -256,7 +254,7 @@ impl PipelineConfig { let (jobs_tx, jobs_rx) = async_channel::bounded::((m * 4).max(8)); let ctx = decode::DecodeCtx { - catalog: catalog.clone(), + log: log.clone(), mapping, oracle, msg_tx: msg_tx.clone(), @@ -268,10 +266,9 @@ impl PipelineConfig { let reorder = reorder::ReorderSink::new( buffer, + log, catalog, subxact_tracker, - schema_events, - pending_sweeps, applicator, ack, jobs_tx, diff --git a/src/emit/pipeline/reorder.rs b/src/emit/pipeline/reorder.rs index f55248a9..c8501b84 100644 --- a/src/emit/pipeline/reorder.rs +++ b/src/emit/pipeline/reorder.rs @@ -23,24 +23,22 @@ 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::catalog::desc_log::{DescriptorLog, LookupResult}; +use crate::catalog::shadow_catalog::ShadowCatalog; use crate::decode::heap_decoder::{DecodedHeap, 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::config::{ConfigResolver, ResolvedConfig}; @@ -54,13 +52,11 @@ 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, @@ -121,10 +117,9 @@ 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, @@ -155,10 +150,9 @@ impl ReorderSink { .unwrap_or_default(); Self { buffer, + log, catalog, subxact_tracker, - schema_events, - pending_sweeps, applicator, ack, jobs_tx, @@ -339,64 +333,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. @@ -518,6 +454,27 @@ impl ReorderSink { /// their drop never replays, so no commit re-triggers this flush. /// Ledger removal persists after each wipe; a crash between re-runs /// an idempotent `TRUNCATE` on the emptied mirror + /// 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(()) + } + pub async fn flush_due_retires(&mut self) -> Result<(), SinkError> { // Disabled resolver no-ops retire_mirror: flushing would drop ledger // entries without wiping mirrors, leaking them for a later CH run @@ -575,36 +532,41 @@ impl ReorderSink { 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))), + let rel = match self.log.descriptor_at(heap.rfn, heap.source_lsn) { + LookupResult::Present(rel) => rel, + LookupResult::ForeignDb => return Ok(()), + // The truncating commit itself retired this rfn (rotation's + // Retired lands at the new generation's bias-early valid_from, + // before the truncate record) — the relation is the chain's + // last Present + LookupResult::Retired => match self.log.present_before(heap.rfn, heap.source_lsn) { + Some(rel) => rel, + None => { + return Err(SinkError::Other(format!( + "truncate descriptor for {:?} at {:#X}: retired with no predecessor", + heap.rfn, heap.source_lsn, + ))); + } + }, + other => { + return Err(SinkError::Other(format!( + "truncate descriptor for {:?} at {:#X}: {other:?}", + heap.rfn, heap.source_lsn, + ))); + } }; 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(()) } @@ -682,7 +644,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 +657,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, @@ -844,11 +806,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); { diff --git a/src/filter/catalog_tracker.rs b/src/filter/catalog_tracker.rs index cfc61b4b..392b8310 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,19 @@ 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 + 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 +280,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 +315,7 @@ impl CatalogTracker { } } self.invalidation_signals_sent += 1; - CatalogSignal::Invalidate + true } /// Query source `pg_class` for every catalog relation (oid < 16384). @@ -379,6 +353,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) @@ -669,7 +646,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); } @@ -678,11 +655,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); } @@ -700,9 +673,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); @@ -713,7 +685,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); } @@ -723,35 +695,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] @@ -759,7 +728,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); } @@ -828,35 +797,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/engine.rs b/src/filter/engine.rs index df320c48..d7edb851 100644 --- a/src/filter/engine.rs +++ b/src/filter/engine.rs @@ -10,19 +10,22 @@ //! `CatalogTracker`. Unrecognised → ToShadow: correctness over bytes, //! wrongly suppressing a catalog record breaks shadow. -use std::collections::HashSet; +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; -use walrus::pg::walparser::{RmId, XLogRecord, XLogRecordBlock}; +use walrus::pg::walparser::{RelFileNode, RmId, XLogRecord, XLogRecordBlock}; use crate::decode::wal_xact::{ XLOG_XACT_ABORT, XLOG_XACT_ABORT_PREPARED, XLOG_XACT_COMMIT, XLOG_XACT_COMMIT_PREPARED, - XLOG_XACT_OPMASK, parse_xact_payload, + XLOG_XACT_INVALIDATIONS, XLOG_XACT_OPMASK, XactPayloadError, parse_xact_invalidations, + parse_xact_payload, }; use crate::filter::catalog_tracker::{CatalogTracker, CatalogTrackerStats}; use crate::filter::classify::{Class, classify}; 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 { @@ -98,14 +101,62 @@ pub(crate) struct FilterSnapshot { } /// Full routing verdict for one record; see [`Filter::decide_record`]. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct Verdict { pub route: Route, - pub signal: CatalogSignal, /// 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>, +} + +/// One catalog-dirty xact's accumulated capture inputs, keyed by the +/// writing xid (top or sub); merged across the tree at its commit. +#[derive(Debug)] +struct DirtyXact { + /// First catalog-touching record LSN under this xid + first_touch: u64, + /// User oid → first pg_class touch LSN under this xid + oids: HashMap, + /// Wrote a capture-all catalog (pg_namespace) + unenumerated: 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 @@ -114,10 +165,15 @@ pub struct Verdict { pub struct Filter { tracker: CatalogTracker, stats: FilterStats, - /// Xids (top or sub) that wrote a catalog-touching record; drained at - /// their commit / abort. Crash-orphaned xids linger, bounded by - /// workload (no commit ever arrives to hold on) - catalog_dirty_xids: HashSet, + /// Xids (top or sub) that wrote a catalog-touching record or logged a + /// descriptor-relevant `XLOG_XACT_INVALIDATIONS` set; drained at their + /// commit / abort. Crash-orphaned xids linger, bounded by workload (no + /// commit ever arrives to hold on) + catalog_dirty: HashMap, + smgr_markers: Arc>, + /// Relcache-inval scope: accept db in {0, this}; `None` (unwired) + /// accepts any db + inval_db_oid: Option, } impl Filter { @@ -125,12 +181,29 @@ impl Filter { Self { tracker: CatalogTracker::new(), stats: FilterStats::default(), - catalog_dirty_xids: HashSet::new(), + catalog_dirty: HashMap::new(), + 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_record(record).route + // 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 { @@ -159,20 +232,21 @@ impl Filter { ) } - /// [`Self::decide_record`] narrowed to `(route, signal)`. - pub fn decide_with_signal(&mut self, record: &XLogRecord) -> (Route, CatalogSignal) { - let v = self.decide_record(record); - (v.route, v.signal) - } - /// 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), plus the - /// catalog-boundary verdict driving the pump's publication hold. - pub fn decide_record(&mut self, record: &XLogRecord) -> Verdict { - let signal = self.tracker.observe(record); + /// 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); // `catalog_touch` marks the record's xid dirty: only paths proving a // catalog relation was written qualify. `Empty`'s None → ToShadow @@ -180,7 +254,7 @@ impl Filter { let (route, catalog_touch) = match class { Class::Catalog => (Route::ToShadow, true), // Relmap update (VACUUM FULL mapped catalog) is Special-class - Class::Special => (Route::ToShadow, signal != CatalogSignal::None), + 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 @@ -200,18 +274,41 @@ impl Filter { 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; if catalog_touch && xid != 0 { - self.catalog_dirty_xids.insert(xid); + let dirty = self.catalog_dirty.entry(xid).or_insert_with(|| DirtyXact { + first_touch: source_lsn, + oids: HashMap::new(), + unenumerated: false, + }); + 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 catalog_boundary = self.observe_xact_end(record); + let boundary = self.observe_xact_end(record, source_lsn, page_magic)?; self.stats .record(class, route, record.header.total_record_length as u64); - Verdict { + Ok(Verdict { route, - signal, - catalog_boundary, - } + catalog_boundary: boundary.is_some(), + boundary, + }) } /// Drain dirty xids at commit / abort. Commit of any dirty xid (top, @@ -219,28 +316,165 @@ impl Filter { /// without holding — rolled-back catalog changes never become visible /// in shadow. Commit records carry the full committed-subxact list /// (`xactGetCommittedChildren`), so no ASSIGNMENT tracking is needed. - fn observe_xact_end(&mut self, record: &XLogRecord) -> bool { - if record.header.resource_manager_id != RmId::Xact as u8 - || self.catalog_dirty_xids.is_empty() - { - return false; + /// 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); + } 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 false; + return Ok(None); } - let payload = parse_xact_payload(info, &record.main_data); - let mut hit = self.catalog_dirty_xids.remove(&record.header.xact_id); + let payload = parse_xact_payload(info, &record.main_data, page_magic)?; + let header_xid = record.header.xact_id; + let mut merged: Option = None; + let mut absorb = |dirty: Option| { + let Some(dirty) = dirty else { return }; + match &mut merged { + None => merged = Some(dirty), + Some(m) => { + m.first_touch = m.first_touch.min(dirty.first_touch); + m.unenumerated |= dirty.unenumerated; + for (oid, lsn) in dirty.oids { + m.oids + .entry(oid) + .and_modify(|l| *l = (*l).min(lsn)) + .or_insert(lsn); + } + } + } + }; + absorb(self.catalog_dirty.remove(&header_xid)); if let Some(x) = payload.twophase_xid { - hit |= self.catalog_dirty_xids.remove(&x); + absorb(self.catalog_dirty.remove(&x)); } for x in &payload.subxacts { - hit |= self.catalog_dirty_xids.remove(x); + absorb(self.catalog_dirty.remove(x)); + } + 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); + } + let mut merged = merged.unwrap_or_else(|| DirtyXact { + // 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) + first_touch: source_lsn, + oids: HashMap::new(), + unenumerated: false, + }); + 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(()); } - hit && is_commit + 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.catalog_dirty.entry(xid).or_insert_with(|| DirtyXact { + first_touch: source_lsn, + oids: HashMap::new(), + unenumerated: false, + }); + 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 { @@ -369,20 +603,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(); @@ -408,16 +628,26 @@ mod tests { } /// `xl_xact_commit` / `xl_xact_abort` main_data: xact_time, then - /// optional xinfo + subxact / twophase sections. - fn xact_end(op: u8, xid: u32, subxacts: &[u32], twophase: Option) -> XLogRecord<'static> { + /// 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() { + 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 } @@ -428,6 +658,16 @@ mod tests { 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()); } @@ -438,30 +678,47 @@ mod tests { 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)); - f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 20000)], 8)); + 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)); + 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)); + 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)); + 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)); - let v = f.decide_record(&xact_end(XLOG_XACT_ABORT, 7, &[], None)); + 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)); + let v = f + .decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None), 0, 0xD116) + .unwrap(); assert!(!v.catalog_boundary, "abort drained the mark"); } @@ -469,17 +726,31 @@ mod tests { 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)); - let v = f.decide_record(&xact_end(XLOG_XACT_COMMIT, 100, &[101, 102], None)); + 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 commit_prepared_matches_prepared_xid() { let mut f = Filter::new(); - f.decide_record(&rec_with_xid(RmId::Heap, &[(5, 1259)], 300)); + 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))); + let v = f + .decide_record( + &xact_end(XLOG_XACT_COMMIT_PREPARED, 0, &[], Some(300)), + 0, + 0xD116, + ) + .unwrap(); assert!(v.catalog_boundary); } @@ -489,8 +760,10 @@ mod tests { let mut f = Filter::new(); let mut r = relmap(5, &[(1259, 50000)]); r.header.xact_id = 9; - f.decide_record(&r); - let v = f.decide_record(&xact_end(XLOG_XACT_COMMIT, 9, &[], None)); + 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" @@ -502,8 +775,13 @@ mod tests { 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).route, Route::ToShadow); - let v = f.decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None)); + 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" @@ -514,8 +792,334 @@ mod tests { 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)); - let v = f.decide_record(&xact_end(XLOG_XACT_COMMIT, 7, &[], None)); + 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/lib.rs b/src/lib.rs index 81676844..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,8 +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::{ - boundary_hold, 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/ops/metrics.rs b/src/ops/metrics.rs index 9044013f..3b65c123 100644 --- a/src/ops/metrics.rs +++ b/src/ops/metrics.rs @@ -140,6 +140,32 @@ pub struct MetricsSnapshot { 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_not_covered_total: u64, + pub desc_lookups_foreign_db_total: u64, } #[derive(Debug, Clone, Default)] @@ -619,6 +645,114 @@ pub fn render(snap: &MetricsSnapshot) -> String { "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_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.", @@ -684,6 +818,14 @@ pub fn render(snap: &MetricsSnapshot) -> String { .unwrap(); writeln!(s, "# TYPE {name} counter").unwrap(); writeln!(s, "{name} {:.3}", snap.catalog_boundary_hold_seconds_total).unwrap(); + let name = "walshadow_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"; 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 99906dfb..09773927 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)] @@ -81,10 +101,11 @@ pub struct Record<'a> { 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>, } pub trait RecordSink { @@ -171,8 +192,8 @@ impl RecordSink for CollectingRecordSink { 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(), }); 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 index a5b3bb94..02440a88 100644 --- a/src/source/boundary_hold.rs +++ b/src/source/boundary_hold.rs @@ -32,6 +32,7 @@ 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; @@ -145,19 +146,32 @@ impl CatalogBoundaryGate { } } -/// [`QueueingRecordSink`] wrapper enacting the hold. Forwards every record, -/// then at a catalog boundary force-flushes the pump-side batch (the commit -/// must not strand in the accumulator while the pump parks — and the flush -/// surfaces any parked worker error first) and parks in -/// [`CatalogBoundaryGate::hold`]. +/// [`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 } + 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 { @@ -183,10 +197,12 @@ impl RecordSink for BoundaryHoldSink { record: &'a Record<'a>, ) -> Pin> + Send + 'a>> { Box::pin(async move { - self.inner.on_record(record).await?; if !record.catalog_boundary { - return Ok(()); + 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 @@ -199,7 +215,15 @@ impl RecordSink for BoundaryHoldSink { self.inner.flush().await?; return Err(hold_err); } - Ok(()) + 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 }) } @@ -435,14 +459,21 @@ mod tests { 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() }; - // Worker fails on the shipped commit; hold's worker_alive check - // wakes with Err and the parked root cause wins 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..4f5a8c0a --- /dev/null +++ b/src/source/catalog_capture.rs @@ -0,0 +1,329 @@ +//! 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`. + +use std::collections::HashMap; +use std::sync::Arc; + +use tokio_postgres::types::Oid; +use walrus::pg::walparser::RelFileNode; + +use crate::catalog::desc_log::{BatchRecord, DescriptorLog, LogEntry, LogValue}; +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, + /// 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(); + + let mut entries: 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(); + let valid_from = if rotated || fresh { + self.marker_for(desc.rfn) + .or_else(|| pg_class_touch.get(&oid).copied()) + .unwrap_or(info.tree_first_touch) + } else { + pg_class_touch + .get(&oid) + .copied() + .unwrap_or(info.tree_first_touch) + }; + if rotated && let Some(old) = &pred_desc { + entries.push(Arc::new(LogEntry { + valid_from, + oid, + rfn: old.rfn, + value: LogValue::Retired, + })); + } + let changed = pred_desc.as_deref() != Some(desc); + if changed { + 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(), + }, + }); + } + } + } + // 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, + 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) + } +} + +/// 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, + }) + } + } +} 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 546333af..1e11c6f6 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -1,4 +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 60ada449..fd62afba 100644 --- a/src/source/queueing_record_sink.rs +++ b/src/source/queueing_record_sink.rs @@ -345,8 +345,8 @@ impl RecordSink for QueueingRecordSink { 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(), }); if self.buf.len() >= self.batch_size { self.flush_buf().await?; @@ -371,14 +371,14 @@ mod tests { } } - 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(()) @@ -388,26 +388,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), ], ); } diff --git a/src/source/wal_stream.rs b/src/source/wal_stream.rs index fefdd105..3c5ebce2 100644 --- a/src/source/wal_stream.rs +++ b/src/source/wal_stream.rs @@ -65,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, @@ -251,7 +257,13 @@ impl WalStream { offset: start_offset, source, })?; - verdict = self.filter.decide_record(&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(); @@ -313,8 +325,8 @@ impl WalStream { next_lsn, page_magic, route, - catalog_signal: verdict.signal, catalog_boundary: verdict.catalog_boundary, + boundary_info: verdict.boundary, }; if let Some(sink) = record_sink.as_deref_mut() { sink.on_record(&record).await?; @@ -802,7 +814,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(), @@ -829,7 +841,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, @@ -854,8 +866,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()); @@ -1000,7 +1012,7 @@ mod tests { let mut ws = WalStream::new(1, SEG, 0).unwrap(); let mut rec = CollectingRecordSink::default(); let mut seg = CollectingSegmentSink::default(); - ws.push(0, &synth_xact_page(), &mut rec, &mut seg) + ws.push(0, &synth_two_record_page(), &mut rec, &mut seg) .await .unwrap(); // synth records are 30 bytes at offsets 40 and 72 diff --git a/src/xact/xact_buffer.rs b/src/xact/xact_buffer.rs index 892a44c7..733d6489 100644 --- a/src/xact/xact_buffer.rs +++ b/src/xact/xact_buffer.rs @@ -46,7 +46,7 @@ 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::{DescriptorLog, LookupResult}; use crate::decode::decoder_sink::{DecoderSinkError, DecoderStats}; use crate::decode::heap_decoder::{ ColumnValue, DecodedHeap, HeapOp, ToastPointer, decode_heap_record, @@ -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; @@ -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, @@ -504,19 +460,18 @@ 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`]). 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 = { @@ -532,12 +487,8 @@ 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' => { + match log.descriptor_at(*rfn, next_lsn) { + LookupResult::Present(rel) if rel.kind == 't' => { let marker = buffer.lock().await.marker_lsn(*rfn); let Some(marker_lsn) = marker else { return Err(XactBufferError::IncompleteToastGeneration { relid: rel.oid }); @@ -545,18 +496,22 @@ pub async fn resolve_stash( barriers.push((rel.oid, marker_lsn)); outcomes.insert(*rfn, StashOutcome::Toast(rel)); } - Ok(_) => { + LookupResult::Present(_) => { outcomes.insert(*rfn, StashOutcome::Skip); } - // 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()), + // Dropped / rotated away by this xid or a later covered commit; + // AEL supersession makes the discard end-state-neutral. Foreign + // db never stashes rows worth keeping; NotCovered = rel never + // reached the log (born + gone inside this xact's family) + LookupResult::Dropped + | LookupResult::Retired + | LookupResult::NotCovered + | LookupResult::ForeignDb => {} } } 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); buf.install_stash_resolution( @@ -1024,9 +979,13 @@ 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, @@ -1863,12 +1822,7 @@ pub async fn detoast_heap( // (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, + log: &DescriptorLog, resolver: &ToastResolver, ) -> std::result::Result, XactBufferError> { let mut pointers: Vec = Vec::new(); @@ -1885,12 +1839,16 @@ 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? + // A heap reaching detoast already decoded once against a covered + // descriptor; anything but Present here is a coverage bug + let rel: Arc = match log.descriptor_at(heap.rfn, heap.source_lsn) { + LookupResult::Present(rel) => rel, + other => { + return Err(XactBufferError::DescriptorNotCovered { + rfn: heap.rfn, + lsn: heap.source_lsn, + got: format!("{other:?}"), + }); } }; let mut uses: HashMap<(u32, u32), u32> = HashMap::new(); @@ -2143,73 +2101,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 +2142,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 +2150,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 @@ -2304,7 +2182,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 +2194,18 @@ 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 + let rel = match self.log.descriptor_by_oid_at(relid, source_lsn) { + LookupResult::Present(r) => r, + _ => { + 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' { @@ -2368,19 +2234,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 +2273,10 @@ 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; // 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 +2288,44 @@ 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()); - } - } - }; - if let Some(c) = self.rel_cache.as_mut() - && c.enabled() + 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 = match self.log.descriptor_at(rfn, record.source_lsn) { + LookupResult::Present(rel) => rel, + // Foreign db / rel that died before the coverage horizon: + // counted row skip, never a stash or a fatal + LookupResult::ForeignDb => { + self.stats + .catalog_not_found + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + return Ok(()); + } + LookupResult::NotCovered + if record.source_lsn <= self.log.covered_through() || txn_xid == 0 => { - c.insert(rfn, resolved.clone()); + 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 + LookupResult::NotCovered | LookupResult::Dropped => { + 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 + 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() @@ -2640,16 +2463,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), @@ -3100,6 +2913,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 +3023,7 @@ mod tests { rel_node: 16400, }, oid: 16400, + toast_oid: 0, namespace_oid: 2200, rel_name: RelName::new("public", "t"), kind: 'r', @@ -3567,7 +3382,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 +3400,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 +3410,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 +3434,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)); } @@ -3684,6 +3496,28 @@ 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(); + } + /// 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. diff --git a/tests/bootstrap_pipeline_ch.rs b/tests/bootstrap_pipeline_ch.rs index 1d12d8b4..56102060 100644 --- a/tests/bootstrap_pipeline_ch.rs +++ b/tests/bootstrap_pipeline_ch.rs @@ -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', diff --git a/tests/common/inproc_harness.rs b/tests/common/inproc_harness.rs index a5ced75d..19c90658 100644 --- a/tests/common/inproc_harness.rs +++ b/tests/common/inproc_harness.rs @@ -109,6 +109,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 +485,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 +501,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?; @@ -620,17 +639,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,27 +666,70 @@ 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("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, + entries, + }, + covered_through, + ) .await - .expect("seed schema-diff baseline"); - let rx = catalog.lock().await.subscribe(); - schema_events = Some(Arc::new(std::sync::Mutex::new(rx))); + .expect("seed descriptor log"); } tune(&mut emitter_cfg); @@ -694,7 +745,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 +754,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 +781,7 @@ async fn build_pipeline_inner( mapping.clone(), stats.clone(), catalog.clone(), + desc_log.clone(), &spill_dir, Some(config_rx.clone()), None, @@ -752,8 +802,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 +821,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, + 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"); diff --git a/tests/desc_log_e2e.rs b/tests/desc_log_e2e.rs new file mode 100644 index 00000000..c6852ac0 --- /dev/null +++ b/tests/desc_log_e2e.rs @@ -0,0 +1,286 @@ +//! 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 std::process::{Command, Stdio}; +use std::time::Duration; + +use walshadow::mapping::{ColumnMapping, TableTarget}; +use walshadow::schema::RelName; +use walshadow::shadow::Shadow; + +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, +}; + +struct PortSlot { + source: u16, + shadow: u16, + ch_tcp: u16, + ch_http: u16, + walsender: u16, +} + +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(); + }) +} + +/// Live 2PC: DDL + DML inside a prepared xact reach CH at COMMIT PREPARED. +/// The commit record's header xid is the finishing backend's; both the +/// capture-keyed events and the buffered rows live under the prepared xid +/// (B2) — pre-fix the drain keyed header xid and stranded them. The table +/// pre-exists (same-xact CREATE + INSERT rows stay fenced — stash item 5, +/// out of scope here). +#[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); + pipeline.shutdown().await.expect("pipeline drains clean"); + let _ = shadow.stop(); + let _ = source.stop(); + + fx::wait_query( + &ch, + "SELECT count() FROM walshadow_test.twophase", + "8", + "prepared xact rows drain at COMMIT PREPARED", + ) + .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; +} diff --git a/tests/desc_log_restart_e2e.rs b/tests/desc_log_restart_e2e.rs new file mode 100644 index 00000000..c555fcec --- /dev/null +++ b/tests/desc_log_restart_e2e.rs @@ -0,0 +1,381 @@ +//! 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 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 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 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/emitter_budget_flush.rs b/tests/emitter_budget_flush.rs index 6fdf00d7..1f23fff8 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', diff --git a/tests/emitter_native_types.rs b/tests/emitter_native_types.rs index 7166b3df..c6ec4c1c 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', diff --git a/tests/emitter_tls.rs b/tests/emitter_tls.rs index d16a9ad3..55e02b93 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', diff --git a/tests/multi_segment_filter.rs b/tests/multi_segment_filter.rs index 6e3a7c02..a5d5febb 100644 --- a/tests/multi_segment_filter.rs +++ b/tests/multi_segment_filter.rs @@ -325,8 +325,8 @@ impl RecordSink for SharedCollectingSink { 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(), }); Ok(()) }) 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/wal_stream_throughput.rs b/tests/wal_stream_throughput.rs index a7c70304..372af5c8 100644 --- a/tests/wal_stream_throughput.rs +++ b/tests/wal_stream_throughput.rs @@ -323,8 +323,8 @@ async fn pump_throughput_breakdown() { 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(), }; // Push then immediately pop to drop, so we measure // clone+drop without growing memory unboundedly. diff --git a/tests/xact_buffer.rs b/tests/xact_buffer.rs index 64739f15..3b70bdc2 100644 --- a/tests/xact_buffer.rs +++ b/tests/xact_buffer.rs @@ -22,6 +22,7 @@ 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, }; @@ -150,11 +151,53 @@ 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, + 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>, + log: &DescriptorLog, resolver: &ToastResolver, xid: u32, commit_ts: i64, @@ -177,7 +220,7 @@ 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, log, resolver).await?; out.push(CommittedTuple { decoded: heap, commit_ts: drain.commit_ts, @@ -240,7 +283,8 @@ async fn commit_drains_in_arrival_order_and_clears_state() { 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; + let seen = drain_all(&mut b, &log, &ToastResolver::disabled(), 7, 12345, 300, &[]) .await .unwrap(); assert_eq!(seen.len(), 2); @@ -310,7 +354,8 @@ async fn commit_drains_spilled_then_in_memory_entries() { .await .unwrap(); } - let seen = drain_all(&mut b, &cat, &ToastResolver::disabled(), 5, 0, 250, &[]) + let log = log_from_catalog(&cat, tmp.path()).await; + let seen = drain_all(&mut b, &log, &ToastResolver::disabled(), 5, 0, 250, &[]) .await .unwrap(); assert_eq!(seen.len(), 5); @@ -356,9 +401,10 @@ async fn commit_merges_top_and_subxact_in_source_lsn_order() { b.on_heap(heap(rfn, 7, 200, HeapOp::Insert, col(3))) .await .unwrap(); + let log = log_from_catalog(&cat, tmp.path()).await; let seen = drain_all( &mut b, - &cat, + &log, &ToastResolver::disabled(), 7, 12345, @@ -419,9 +465,10 @@ async fn detoast_concatenates_uncompressed_chunks_into_text() { b.on_heap(heap(rfn, 33, 0, HeapOp::Insert, vec![id_col, body_ptr])) .await .unwrap(); + let log = log_from_catalog(&cat, tmp.path()).await; let seen = drain_all( &mut b, - &cat, + &log, &ToastResolver::disabled(), 33, 12345, @@ -485,7 +532,8 @@ 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 log = log_from_catalog(&cat, tmp.path()).await; + let err = drain_all(&mut b, &log, &resolver, 42, 0, 200, &[]) .await .expect_err("missing chunk surfaces"); match err { From 7e6566d82317d3916b93c6dfb33329d8dd63c929 Mon Sep 17 00:00:00 2001 From: serprex <159546+serprex@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:57:10 +0000 Subject: [PATCH 3/4] Plan transactions before emitting rows Build and validate each committed transaction before applying schema, config, or ClickHouse side effects. Freeze descriptors and routes in a checksummed plan, spill large plans to disk, then replay only after planning succeeds Defer records from catalog-dirty transactions until commit, decode them against committed descriptors, and fail ambiguous layouts or unsupported WAL shapes instead of silently dropping rows --- .github/workflows/ci.yml | 18 + architecture/decoder.dot | 9 +- architecture/decoder.svg | 732 ++++++++++---------- architecture/emitter.dot | 13 +- architecture/emitter.svg | 605 +++++++++-------- architecture/filter.dot | 10 +- architecture/filter.svg | 211 +++--- architecture/internals.dot | 6 +- architecture/internals.svg | 815 +++++++++++----------- architecture/oracle.dot | 68 +- architecture/oracle.svg | 601 ++++++++--------- architecture/xact.dot | 6 +- architecture/xact.svg | 457 ++++++------- plans/GLOSSARY.md | 25 +- plans/INDEX.md | 19 +- plans/TOAST.md | 6 +- plans/config.md | 36 +- plans/desc_log.md | 41 +- plans/emitter.md | 120 +++- plans/filter.md | 19 + plans/future/INDEX.md | 1 - plans/future/xact_stash.md | 809 ---------------------- plans/ops.md | 11 +- plans/oracle.md | 52 +- plans/overview.md | 17 +- plans/source.md | 4 +- plans/xact.md | 90 ++- src/atomic_stats.rs | 10 +- src/backfill/backfill_staging.rs | 3 +- src/backfill/backup_backfill.rs | 57 +- src/backfill/copy_backfill.rs | 2 + src/bin/stream.rs | 141 ++-- src/catalog/compat.rs | 238 +++++++ src/catalog/desc_log.rs | 684 ++++++++++++++++++- src/catalog/mod.rs | 1 + src/config.rs | 18 +- src/decode/codecs.rs | 3 +- src/decode/decoder_sink.rs | 10 + src/decode/heap_decoder.rs | 83 ++- src/decode/wal_xact.rs | 6 + src/emit/ch_ddl.rs | 67 +- src/emit/ch_emitter.rs | 48 +- src/emit/mod.rs | 1 + src/emit/pipeline/batcher.rs | 118 +++- src/emit/pipeline/bootstrap.rs | 62 +- src/emit/pipeline/decode.rs | 106 +-- src/emit/pipeline/mod.rs | 30 +- src/emit/pipeline/plan_spool.rs | 818 ++++++++++++++++++++++ src/emit/pipeline/planner.rs | 711 +++++++++++++++++++ src/emit/pipeline/reorder.rs | 581 ++++++++++------ src/emit/route.rs | 63 ++ src/filter/dirty_tree.rs | 273 ++++++++ src/filter/engine.rs | 268 ++++++-- src/filter/mod.rs | 1 + src/mapping.rs | 48 +- src/ops/control.rs | 15 +- src/ops/metrics.rs | 284 +++++++- src/ops/oracle.rs | 167 +---- src/record.rs | 6 + src/source/catalog_capture.rs | 189 +++++- src/source/queueing_record_sink.rs | 1 + src/source/wal_stream.rs | 1 + src/xact/spill.rs | 340 +++++++--- src/xact/xact_buffer.rs | 1012 +++++++++++++++++++++++++--- tests/bootstrap_pipeline_ch.rs | 6 +- tests/common/inproc_harness.rs | 52 +- tests/composite_pkey.rs | 41 +- tests/control_plane_e2e.rs | 87 ++- tests/desc_log_e2e.rs | 218 ++++-- tests/desc_log_restart_e2e.rs | 40 +- tests/dirty_admission_e2e.rs | 526 +++++++++++++++ tests/emitter_budget_flush.rs | 4 +- tests/emitter_native_types.rs | 2 +- tests/emitter_tls.rs | 2 +- tests/multi_segment_filter.rs | 1 + tests/oracle.rs | 50 +- tests/oracle_types_e2e.rs | 44 +- tests/runtime_config_e2e.rs | 138 +++- tests/soft_delete_cdc.rs | 41 +- tests/subxact.rs | 40 +- tests/toast_rewrite_e2e.rs | 30 +- tests/types_sweep.rs | 42 +- tests/wal_stream_throughput.rs | 1 + tests/weird_identifiers.rs | 42 +- tests/xact_buffer.rs | 219 ++++-- 85 files changed, 8785 insertions(+), 4108 deletions(-) delete mode 100644 plans/future/xact_stash.md create mode 100644 src/catalog/compat.rs create mode 100644 src/emit/pipeline/plan_spool.rs create mode 100644 src/emit/pipeline/planner.rs create mode 100644 src/emit/route.rs create mode 100644 src/filter/dirty_tree.rs create mode 100644 tests/dirty_admission_e2e.rs 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 717d98c1..43ecb546 100644 --- a/plans/GLOSSARY.md +++ b/plans/GLOSSARY.md @@ -107,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)) @@ -324,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 @@ -555,14 +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 unresolvable at record time (rewrite generation, -same-xact CREATE/TRUNCATE + INSERT), admitted by generation marker, -resolved at commit against the descriptor log at the commit's -`next_lsn` and decoded in the drain merge; tombstoned/uncovered → -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 72519451..36f8ac20 100644 --- a/plans/INDEX.md +++ b/plans/INDEX.md @@ -10,28 +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 client, `RelDescriptor`, reconnect resilience - [desc_log.md](desc_log.md) — durable descriptor log: boundary - capture, interval lookups, replay-from-log, seed + coverage horizon, - GC against the resolved floor + 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 @@ -42,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 596f931c..3449c0f2 100644 --- a/plans/TOAST.md +++ b/plans/TOAST.md @@ -205,9 +205,9 @@ 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`); lifting the fence is - [future/xact_stash.md](future/xact_stash.md) scope. A toast + 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 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/desc_log.md b/plans/desc_log.md index d5207424..0083cab9 100644 --- a/plans/desc_log.md +++ b/plans/desc_log.md @@ -70,6 +70,22 @@ 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: +metadata-only changes (renames, defaults) and appended nullable / +missing-value columns qualify; physical changes — type, typmod, typlen, +alignment, attnum reuse, not-null append without a missing value — do +not. An incompatible in-place change (same rfn, no rotation) instead +publishes an `Ambiguity` interval `[first_touch, next_lsn)` alongside +the final `Present`: within it no single descriptor provably decodes the +rfn's rows. Scope is `Rfn`, `Oid`, or conservatively `Database` when +affected relations can't be enumerated; reasons enumerate +`UnknownAffectedRelation / UnknownMutationPosition / +MultipleIncompatibleLayouts / NeverVisibleGeneration / +IncompleteInvalidation`. Ambiguities are batch records like entries — +replay-from-log reproduces the same intervals every boot. + Toast rels ('t') capture entries and `Dropped` events only (the retire ledger consumes those); indexes are excluded entirely. @@ -101,18 +117,25 @@ enabled config picks up existing rels without log mutation. ## Decode reads `descriptor_at(rfn, lsn)` / `descriptor_by_oid_at(oid, lsn)` return -`Present | Dropped | Retired | NotCovered | ForeignDb`: +`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; Retired skips (rows can't outlive the - rotation) + NotCovered are counted skips; NotCovered/Dropped/Ambiguous with a + live xid stash for commit-time resolution ([xact.md](xact.md) + Commit-time stash); Retired skips (rows can't outlive the rotation) - stash resolution at commit `next_lsn`: Present toast → chunk decode - behind its marker barrier, Present ordinary → fenced (stash item 5, - [future/xact_stash.md](future/xact_stash.md)), tombstones discard -- decode pool: per-job memo over the log (mapping writes land inside the - barrier fence, between jobs); anything but Present on a drained record - is fatal except ForeignDb / pre-horizon skips + behind its marker barrier, Present ordinary → raw decode under the + commit-resolution descriptor, Ambiguous → fatal fail-closed, + tombstones discard +- 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) diff --git a/plans/emitter.md b/plans/emitter.md index 97724c47..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 @@ -47,11 +47,15 @@ daemon's `QueueingRecordSink` (off the WAL pump task, so replay gates never pace wire delivery). Only `RM_XACT_ID` records reach its match: - COMMIT — stash resolution against the descriptor log at the commit's - `next_lsn`, `XactBuffer::drain_committed` under the drain xid - (prepared xid for COMMIT PREPARED), 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 + `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 @@ -504,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 22566d22..2a5c213d 100644 --- a/plans/filter.md +++ b/plans/filter.md @@ -91,6 +91,25 @@ 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 diff --git a/plans/future/INDEX.md b/plans/future/INDEX.md index 671eb288..1ae6a350 100644 --- a/plans/future/INDEX.md +++ b/plans/future/INDEX.md @@ -11,7 +11,6 @@ surface; promote into `plans/` once built * [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/xact_stash.md b/plans/future/xact_stash.md deleted file mode 100644 index 438182e3..00000000 --- a/plans/future/xact_stash.md +++ /dev/null @@ -1,809 +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 - -The descriptor log ([../desc_log.md](../desc_log.md)) is this journal: -capture-time descriptor batches, identity-bound header, replay-from-log -determinism. The stash fence lift consumes it via -`descriptor_at(rfn, commit.next_lsn)` instead of building a parallel -store. Original requirements, all satisfied there: - -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 c858fef2..c62fcaa5 100644 --- a/plans/overview.md +++ b/plans/overview.md @@ -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 @@ -188,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/source.md b/plans/source.md index dc74a817..aebf0437 100644 --- a/plans/source.md +++ b/plans/source.md @@ -263,8 +263,8 @@ WAL at last COMMIT, kill-restart idle catchup never resolves [`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 future -catalog capture samples shadow at ([future/xact_stash.md](future/xact_stash.md)) +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 diff --git a/plans/xact.md b/plans/xact.md index 9829a2f6..ab93df3d 100644 --- a/plans/xact.md +++ b/plans/xact.md @@ -107,23 +107,55 @@ status line ## Commit-time stash -Records on a filenode the descriptor log cannot resolve at record time -(the creating xact's boundary hasn't captured yet: 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 lookup -is skipped — the xact's own records can never resolve. 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), installs per-filenode verdicts the drain merge -consumes (toast → decode like live chunks, ordinary heap → fenced skip, -tombstoned/uncovered → 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 +- 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, Dropped, or Ambiguous defers + to commit-time resolution; ForeignDb / pre-horizon NotCovered / + Retired are counted skips (rows can't outlive the commit) + +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)`: 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 +- Ambiguous → fatal `XactBufferError::StashAmbiguous`: no descriptor + proven safe, neither decode nor discard is sound; operator takes a + fresh snapshot +- 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 @@ -184,17 +216,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 @@ -204,7 +243,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 @@ -237,8 +278,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 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_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/backup_backfill.rs b/src/backfill/backup_backfill.rs index ab01eaf3..2abb9087 100644 --- a/src/backfill/backup_backfill.rs +++ b/src/backfill/backup_backfill.rs @@ -61,6 +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::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, @@ -76,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; @@ -323,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 @@ -852,9 +855,11 @@ async fn replay_gap( 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, @@ -889,9 +894,18 @@ struct ReplaySink { 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, @@ -995,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; }; @@ -1009,19 +1023,28 @@ impl ReplaySink { continue; } let rel = rel.clone(); - let value_permit = - detoast_heap(&mut heap, spool, &ref_maps, &self.log, &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 @@ -1036,9 +1059,9 @@ impl ReplaySink { .send(BatcherMsg::Row(RoutedRow { seq, rel, - mapping, + route, committed: CommittedTuple { - decoded: heap, + decoded: heap.decoded, commit_ts, commit_lsn, }, diff --git a/src/backfill/copy_backfill.rs b/src/backfill/copy_backfill.rs index 11b46c79..22202d1f 100644 --- a/src/backfill/copy_backfill.rs +++ b/src/backfill/copy_backfill.rs @@ -1130,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); diff --git a/src/bin/stream.rs b/src/bin/stream.rs index 5c3633cb..94571ccc 100644 --- a/src/bin/stream.rs +++ b/src/bin/stream.rs @@ -345,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, @@ -998,29 +990,22 @@ async fn run_session( // 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. @@ -1158,6 +1143,9 @@ async fn run_session( .seed( walshadow::desc_log::BatchRecord { captured_at: covered_through, + commit_lsn: 0, + observations: Vec::new(), + ambiguities: Vec::new(), entries, }, covered_through, @@ -1223,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. @@ -1768,6 +1756,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) }; @@ -2142,7 +2132,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", @@ -2340,6 +2330,8 @@ struct DrainResident { chunks: u64, rows: u64, spool: u64, + raw_pending_rows: u64, + raw_pending_bytes: u64, } #[allow(clippy::too_many_arguments)] @@ -2435,15 +2427,79 @@ 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.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, + descriptor_ambiguous_by_reason: [ + log_stats.ambiguous_unknown_relation.load(Ordering::Relaxed), + log_stats.ambiguous_unknown_position.load(Ordering::Relaxed), + log_stats + .ambiguous_incompatible_layouts + .load(Ordering::Relaxed), + log_stats.ambiguous_never_visible.load(Ordering::Relaxed), + log_stats + .ambiguous_incomplete_invalidation + .load(Ordering::Relaxed), + ], emitter_rows_total: emitter_stats .map(|s| s.rows_emitted.load(Ordering::Relaxed)) .unwrap_or(0), @@ -2484,12 +2540,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), @@ -2510,6 +2560,7 @@ async fn populate_metrics( 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, @@ -2518,6 +2569,7 @@ async fn populate_metrics( 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), @@ -2838,7 +2890,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, @@ -2860,6 +2912,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 diff --git a/src/catalog/compat.rs b/src/catalog/compat.rs new file mode 100644 index 00000000..0ebb26e0 --- /dev/null +++ b/src/catalog/compat.rs @@ -0,0 +1,238 @@ +//! Physical compatibility predicate: can the final committed descriptor +//! decode tuples written earlier in the same dirty interval? +//! +//! Bias rejects: capture publishes an ambiguity interval on any transition +//! not on the proven-safe list, never guesses. Compared fields are what +//! tuple walking + value interpretation read: attnum sequence, dropped +//! slots, attlen/attalign/attbyval, type oid + typmod + storage, +//! missing-value semantics. Rename, replica identity, and not-null are +//! metadata for decode purposes +//! +//! 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}; + +/// `Ok(())` when `new` provably decodes tuples formatted under `old`; +/// `Err` names the first failing check +pub fn compatible_reader(old: &RelDescriptor, new: &RelDescriptor) -> Result<(), &'static str> { + if old.oid != new.oid { + return Err("oid mismatch"); + } + if old.rfn != new.rfn { + return Err("filenode rotated"); + } + if old.kind != new.kind { + return Err("relkind change"); + } + if old.persistence != new.persistence { + return Err("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("toast relation change"); + } + if new.attributes.len() < old.attributes.len() { + return Err("attribute truncation"); + } + for (o, n) in old.attributes.iter().zip(&new.attributes) { + slot_compatible(o, n)?; + } + // 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("appended not-null column without missing value"); + } + } + Ok(()) +} + +fn slot_compatible(o: &RelAttr, n: &RelAttr) -> Result<(), &'static str> { + if o.attnum != n.attnum { + return Err("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("physical walk fields change"); + } + if o.dropped && !n.dropped { + // PG re-adds at a fresh attnum, never resurrects a dropped slot + return Err("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(()); + } + if o.type_oid != n.type_oid || o.typmod != n.typmod { + return Err("type or typmod change"); + } + if o.type_storage != n.type_storage { + return Err("storage change"); + } + // Tuples shorter than attnum read the missing value; a different one + // reinterprets history + if o.missing_text != n.missing_text { + return Err("missing value 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).is_err()); + let mut typmod = rel(vec![attr(1, 23, 4)]); + typmod.attributes[0].typmod = 12; + assert!(compatible_reader(&old, &typmod).is_err()); + let mut storage = rel(vec![attr(1, 23, 4)]); + storage.attributes[0].type_storage = 'e'; + assert!(compatible_reader(&old, &storage).is_err()); + let mut missing = rel(vec![attr(1, 23, 4)]); + missing.attributes[0].missing_text = Some("1".into()); + assert!(compatible_reader(&old, &missing).is_err()); + let truncated = rel(vec![]); + assert!(compatible_reader(&old, &truncated).is_err()); + let reorder = rel(vec![attr(2, 23, 4)]); + assert!(compatible_reader(&old, &reorder).is_err()); + } + + #[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 index 413f0363..b3466671 100644 --- a/src/catalog/desc_log.rs +++ b/src/catalog/desc_log.rs @@ -39,6 +39,16 @@ //! 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` +//! //! Identity keys the full physical `RelFileNode`: PG guarantees //! relfilenumber uniqueness only per database of one tablespace //! (`GetNewRelFileNumber`, PG `src/backend/catalog/catalog.c`), so @@ -68,7 +78,7 @@ pub const CKPT_FILE: &str = "desc_log.ckpt"; pub const TAIL_FILE: &str = "desc_log.tail"; const MAGIC: &[u8; 2] = b"WL"; -const VERSION: u16 = 1; +const VERSION: u16 = 2; /// Reject frames past this before allocating: no realistic batch (thousands /// of descriptors) approaches it, garbage lengths do const MAX_FRAME: u32 = 256 * 1024 * 1024; @@ -134,20 +144,86 @@ pub struct LogEntry { 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), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AmbiguityReason { + UnknownAffectedRelation, + UnknownMutationPosition, + MultipleIncompatibleLayouts, + NeverVisibleGeneration, + IncompleteInvalidation, +} + #[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 @@ -167,6 +243,9 @@ struct Slot { 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, @@ -175,6 +254,16 @@ struct Index { impl Index { 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, @@ -218,11 +307,18 @@ crate::atomic_stats! { 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, + /// `descriptor_ambiguous_total{reason}` split of `lookups_ambiguous` + pub ambiguous_unknown_relation, + pub ambiguous_unknown_position, + pub ambiguous_incompatible_layouts, + pub ambiguous_never_visible, + pub ambiguous_incomplete_invalidation, } } @@ -319,30 +415,98 @@ impl DescriptorLog { } 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 LookupResult::ForeignDb; + return Err(LookupResult::ForeignDb); } let idx = self.index.read().unwrap(); - let result = lookup(idx.by_rfn.get(&rfn), lsn); - self.count(&result); + // Ambiguity precedes the chain: a chain entry inside an ambiguous + // interval is not proven safe for rows there + 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 = lookup(idx.by_oid.get(&oid), lsn); - self.count(&result); + 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(a) => { + self.stats.lookups_ambiguous.fetch_add(1, Relaxed); + let by_reason = match a.reason { + AmbiguityReason::UnknownAffectedRelation => { + &self.stats.ambiguous_unknown_relation + } + AmbiguityReason::UnknownMutationPosition => { + &self.stats.ambiguous_unknown_position + } + AmbiguityReason::MultipleIncompatibleLayouts => { + &self.stats.ambiguous_incompatible_layouts + } + AmbiguityReason::NeverVisibleGeneration => &self.stats.ambiguous_never_visible, + AmbiguityReason::IncompleteInvalidation => { + &self.stats.ambiguous_incomplete_invalidation + } + }; + by_reason.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), }; @@ -386,6 +550,13 @@ impl DescriptorLog { 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() + } + /// Oids whose newest entry is `Present` — capture-all diffs its SQL /// enumeration against this to tombstone vanished relations pub fn present_oids(&self) -> Vec { @@ -407,10 +578,7 @@ impl DescriptorLog { let idx = self.index.read().unwrap(); idx.by_oid .values() - .filter_map(|chain| match lookup(Some(chain), lsn) { - LookupResult::Present(d) => Some(d), - _ => None, - }) + .filter_map(|chain| Some(lookup_spanned(Some(chain), lsn).ok()?.0)) .collect() } @@ -456,8 +624,11 @@ impl DescriptorLog { file: TAIL_FILE, offset: w.tail_len, detail: format!( - "append at captured_at {:#x} diverges from stored batch", + "append at captured_at {:#x} diverges from stored batch \ + (digest {:#010x} vs stored {:#010x})", batch.captured_at, + batch.digest(), + existing.digest(), ), }); } @@ -475,8 +646,8 @@ impl DescriptorLog { Ok(()) } - /// Compact when the tail outgrew [`GC_TAIL_BYTES`] or at least - /// [`GC_DEAD_ENTRIES`] entries are droppable below `floor`. Retention + /// 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 @@ -485,16 +656,17 @@ impl DescriptorLog { /// 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(); - let retained = compute_retained(&idx, floor); - let dropped = idx.entries_total - retained.entries_total; - (retained, dropped) + // 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) }; - let tail_bytes = w.tail_len - w.header_len; - if dropped_entries < GC_DEAD_ENTRIES && tail_bytes < GC_TAIL_BYTES { - return Ok(false); - } self.gc_locked(w, retained, dropped_entries as u64, floor) .await?; Ok(true) @@ -557,24 +729,37 @@ impl DescriptorLog { } } -fn lookup(chain: Option<&Vec>, lsn: u64) -> LookupResult { +/// First interval covering `lsn` under `[from_lsn, through_lsn)`; lists stay +/// `(from_lsn, through_lsn)`-sorted so overlap resolution is deterministic +fn ambiguity_covering(list: Option<&Vec>>, lsn: u64) -> Option> { + list? + .iter() + .find(|a| a.from_lsn <= lsn && 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 LookupResult::NotCovered; + 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 LookupResult::NotCovered; + return Err(LookupResult::NotCovered); }; match &slot.entry.value { - LogValue::Present(d) => LookupResult::Present(d.clone()), - LogValue::Dropped => LookupResult::Dropped, - LogValue::Retired => LookupResult::Retired, + LogValue::Present(d) => Ok((d.clone(), slot.valid_from)), + LogValue::Dropped => Err(LookupResult::Dropped), + LogValue::Retired => Err(LookupResult::Retired), } } -fn compute_retained(idx: &Index, floor: u64) -> Index { - // Entry identity by allocation: each Arc is inserted once and - // shared between its batch and both chains +/// 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)]; @@ -584,6 +769,22 @@ fn compute_retained(idx: &Index, floor: u64) -> Index { 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, @@ -600,9 +801,20 @@ fn compute_retained(idx: &Index, floor: u64) -> Index { .filter(|e| e.valid_from > floor || keep.contains(&Arc::as_ptr(e))) .cloned() .collect(); - if !entries.is_empty() { + // 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, })); } @@ -666,6 +878,7 @@ fn encode_meta(covered_through: u64, floor_at_write: u64) -> Vec { 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); @@ -682,6 +895,56 @@ fn encode_batch(batch: &BatchRecord) -> Vec { 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 } @@ -732,6 +995,20 @@ fn encode_descriptor(out: &mut Vec, d: &RelDescriptor) { } } +/// 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), @@ -915,13 +1192,16 @@ fn load_frames( // ckpt/tail overlap from a crash between GC's ckpt // write and tail truncate Some(existing) if **existing == batch => {} - Some(_) => { + Some(existing) => { return Err(DescLogError::Corrupt { file, offset: frame_start as u64, detail: format!( - "batch at captured_at {:#x} diverges from earlier copy", + "batch at captured_at {:#x} diverges from earlier copy \ + (digest {:#010x} vs {:#010x})", batch.captured_at, + batch.digest(), + existing.digest(), ), }); } @@ -985,6 +1265,7 @@ fn check_identity(cur: &mut Cur<'_>, ours: &DescLogIdentity) -> Result<()> { 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 { @@ -1008,8 +1289,75 @@ fn decode_batch(cur: &mut Cur<'_>) -> Result { 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()? { + 0 => AmbiguityReason::UnknownAffectedRelation, + 1 => AmbiguityReason::UnknownMutationPosition, + 2 => AmbiguityReason::MultipleIncompatibleLayouts, + 3 => AmbiguityReason::NeverVisibleGeneration, + 4 => AmbiguityReason::IncompleteInvalidation, + 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, }) } @@ -1201,10 +1549,22 @@ mod tests { 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() } @@ -1488,6 +1848,42 @@ mod tests { } } + /// `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(); @@ -1702,4 +2098,228 @@ mod tests { 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() + .ambiguous_unknown_position + .load(std::sync::atomic::Ordering::Relaxed), + 1, + "reason-labelled counter", + ); + 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 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 dir rejects at open, ckpt or tail alike; the + // explicit epoch reset (--ignore-cursor / re-bootstrap) is the only + // way forward, never silent translation + for file in [TAIL_FILE, CKPT_FILE] { + let path = tmp.path().join(file); + let orig = std::fs::read(&path).unwrap(); + let mut bytes = orig.clone(); + bytes[2] = 1; // 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(1)), "{file}"); + 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 4c8ecd75..e617b549 100644 --- a/src/catalog/mod.rs +++ b/src/catalog/mod.rs @@ -1,3 +1,4 @@ +pub mod compat; pub mod desc_log; pub mod shadow; pub mod shadow_catalog; diff --git a/src/config.rs b/src/config.rs index c6301571..714d19fa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -291,8 +291,8 @@ impl ConfigResolver { /// 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, republish. In-flight - /// rows already dispatched still drain; further rows drop at - /// `lookup_mapping`. + /// 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); @@ -417,7 +417,13 @@ impl ConfigResolver { &prev.columns, ); self.rejections.store(rejections, Ordering::Relaxed); - *self.mapping.write().await = resolved.tables.clone(); + *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 // Err only when every receiver dropped (daemon tearing down); ignore @@ -602,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); } @@ -649,7 +655,7 @@ mod tests { } fn dummy_handles() -> MappingHandle { - Arc::new(tokio::sync::RwLock::new(HashMap::new())) + crate::mapping::mapping_handle(HashMap::new()) } #[test] 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 7f1e406f..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, diff --git a/src/decode/wal_xact.rs b/src/decode/wal_xact.rs index 1a3bcfb2..a2fb1b73 100644 --- a/src/decode/wal_xact.rs +++ b/src/decode/wal_xact.rs @@ -87,6 +87,12 @@ pub(crate) struct XactCommitPayload { #[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)> { let top = take_u32(&mut data)?; let count = take_count(&mut data)?; diff --git a/src/emit/ch_ddl.rs b/src/emit/ch_ddl.rs index fce18795..59fee310 100644 --- a/src/emit/ch_ddl.rs +++ b/src/emit/ch_ddl.rs @@ -490,7 +490,63 @@ impl DdlApplicator { if let Some(r) = &self.resolver { r.register_derived_mapping(rel, mapping).await; } else { - self.mapping.write().await.insert(rel.clone(), mapping); + 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))) + } } } @@ -506,7 +562,7 @@ impl DdlApplicator { if let Some(r) = &self.resolver { r.forget_derived_mapping(rel).await; } else { - self.mapping.write().await.remove(rel); + Arc::make_mut(&mut *self.mapping.write().await).remove(rel); } } @@ -560,7 +616,7 @@ impl DdlApplicator { /// `apply_changed`) async fn mutate_mapping_for_diff(mapping: &MappingHandle, new: &RelDescriptor, diff: &SchemaDiff) { let mut m = mapping.write().await; - let Some(target_mapping) = m.get_mut(&new.rel_name) else { + let Some(target_mapping) = Arc::make_mut(&mut *m).get_mut(&new.rel_name) else { return; }; fold_diff_into_mapping(target_mapping, new, diff); @@ -692,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() { @@ -1056,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(); @@ -1083,7 +1138,7 @@ mod tests { )] .into_iter() .collect(); - let handle: MappingHandle = Arc::new(tokio::sync::RwLock::new(map)); + let handle = crate::mapping::mapping_handle(map); let new = desc( "orders", vec![ diff --git a/src/emit/ch_emitter.rs b/src/emit/ch_emitter.rs index fd694162..8ef8432f 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,34 @@ 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, + 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, 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 4bed80e5..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; @@ -449,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 { @@ -588,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 398700cf..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), @@ -529,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); @@ -554,6 +570,8 @@ mod tests { stats.clone(), ToastResolver::disabled(), mem_spool(), + false, + None, )); let mut by_seq: HashMap = HashMap::new(); @@ -581,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); @@ -604,6 +622,8 @@ mod tests { stats.clone(), ToastResolver::disabled(), mem_spool(), + false, + None, )); let mut seqs: Vec = Vec::new(); @@ -627,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); @@ -652,6 +672,8 @@ mod tests { stats.clone(), resolver, mem_spool(), + false, + None, )); let mut rows = Vec::new(); @@ -684,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); @@ -715,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(); @@ -744,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); @@ -771,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 9a0f0c94..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,25 +11,19 @@ //! (`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::mpsc; use tokio::task::JoinHandle; -use walrus::pg::walparser::RelFileNode; -use std::collections::HashMap; - -use crate::catalog::desc_log::{DescriptorLog, LookupResult}; -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, detoast_heap}; @@ -43,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 log: 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). @@ -87,102 +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>, ) -> Result { - // Per-job memo: mapping mutations apply inside the barrier fence, - // between jobs, so nothing invalidates within one job - let mut memo: HashMap, Arc)> = HashMap::new(); 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.log, &ctx.resolver) + 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); - // Memo hit: no mapping read. Skip/error arms are never memoised, so - // `foreign_db_rows_skipped`/`unsupported_relations` count per row. - let (rel, mapping) = match memo.entry(heap.rfn) { - Entry::Occupied(e) => e.get().clone(), - Entry::Vacant(slot) => { - let rel = match ctx.log.descriptor_at(heap.rfn, heap.source_lsn) { - LookupResult::Present(rel) => rel, - // Physical WAL carries the whole cluster; skip foreign-DB rows - LookupResult::ForeignDb => { - ctx.stats - .foreign_db_rows_skipped - .fetch_add(1, Ordering::Relaxed); - continue; - } - // Rel died before the coverage horizon: its end state is - // already in CH / backfill, nothing to route - LookupResult::NotCovered if heap.source_lsn <= ctx.log.covered_through() => { - ctx.stats - .foreign_db_rows_skipped - .fetch_add(1, Ordering::Relaxed); - continue; - } - // Drained records must have coverage — anything else is - // a log bug; a silent skip would shed rows invisibly - other => { - return Err(format!( - "descriptor for {:?} at {:#X} not covered: {other:?}", - heap.rfn, heap.source_lsn, - )); - } - }; - let Some(mapping) = - crate::emit::pipeline::lookup_mapping(&ctx.mapping, &rel.rel_name, &ctx.stats) - .await - else { - continue; - }; - slot.insert((rel, mapping)).clone() - } - }; + 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, diff --git a/src/emit/pipeline/mod.rs b/src/emit/pipeline/mod.rs index 2ccf874d..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; 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 { @@ -254,8 +237,6 @@ impl PipelineConfig { let (jobs_tx, jobs_rx) = async_channel::bounded::((m * 4).max(8)); let ctx = decode::DecodeCtx { - log: log.clone(), - mapping, oracle, msg_tx: msg_tx.clone(), stats: stats.clone(), @@ -264,6 +245,7 @@ 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, @@ -281,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..0532a01c --- /dev/null +++ b/src/emit/pipeline/planner.rs @@ -0,0 +1,711 @@ +//! 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 (ambiguous / never-visible generations fail closed) +//! - operation supported with logical tuple data → raw operation policy +//! ([`FailClosedReason`](crate::xact::xact_buffer::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, 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 +pub fn drain_reason(e: &XactBufferError) -> &'static str { + match e { + XactBufferError::OrdinaryFailClosed { .. } => "fail_closed", + 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 = XactBufferError::OrdinaryFailClosed { + relid: 1, + lsn: 2, + reason: FailClosedReason::ImageOnly, + }; + assert_eq!(drain_reason(&fail_closed), "fail_closed"); + 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 c8501b84..743fee03 100644 --- a/src/emit/pipeline/reorder.rs +++ b/src/emit/pipeline/reorder.rs @@ -23,9 +23,9 @@ use std::collections::{HashMap, HashSet}; use tokio::sync::{Mutex, mpsc, oneshot, watch}; use walrus::pg::walparser::RmId; -use crate::catalog::desc_log::{DescriptorLog, LookupResult}; +use crate::catalog::desc_log::DescriptorLog; use crate::catalog::shadow_catalog::ShadowCatalog; -use crate::decode::heap_decoder::{DecodedHeap, HeapOp}; +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}; @@ -37,15 +37,17 @@ use crate::decode::wal_xact::{ 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, -}; +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; @@ -111,6 +113,24 @@ 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 { @@ -132,22 +152,26 @@ 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, @@ -165,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 @@ -223,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 @@ -250,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( @@ -387,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(()); @@ -447,13 +471,6 @@ impl ReorderSink { Ok(()) } - /// Retire below persisted resolved floor - /// - /// Restart resumes at the floor, DROP lock excludes later referrers. - /// Pub for boot: entries due at resume must retire during standup — - /// their drop never replays, so no commit re-triggers this flush. - /// Ledger removal persists after each wipe; a crash between re-runs - /// an idempotent `TRUNCATE` on the emptied mirror /// 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 @@ -475,6 +492,13 @@ impl ReorderSink { Ok(()) } + /// Retire below persisted resolved floor + /// + /// Restart resumes at the floor, DROP lock excludes later referrers. + /// Pub for boot: entries due at resume must retire during standup — + /// their drop never replays, so no commit re-triggers this flush. + /// Ledger removal persists after each wipe; a crash between re-runs + /// an idempotent `TRUNCATE` on the emptied mirror pub async fn flush_due_retires(&mut self) -> Result<(), SinkError> { // Disabled resolver no-ops retire_mirror: flushing would drop ledger // entries without wiping mirrors, leaking them for a later CH run @@ -528,33 +552,14 @@ 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 self.log.descriptor_at(heap.rfn, heap.source_lsn) { - LookupResult::Present(rel) => rel, - LookupResult::ForeignDb => return Ok(()), - // The truncating commit itself retired this rfn (rotation's - // Retired lands at the new generation's bias-early valid_from, - // before the truncate record) — the relation is the chain's - // last Present - LookupResult::Retired => match self.log.present_before(heap.rfn, heap.source_lsn) { - Some(rel) => rel, - None => { - return Err(SinkError::Other(format!( - "truncate descriptor for {:?} at {:#X}: retired with no predecessor", - heap.rfn, heap.source_lsn, - ))); - } - }, - other => { - return Err(SinkError::Other(format!( - "truncate descriptor for {:?} at {:#X}: {other:?}", - heap.rfn, heap.source_lsn, - ))); - } - }; + // 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 @@ -571,70 +576,154 @@ impl ReorderSink { 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( @@ -707,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); @@ -824,6 +913,112 @@ 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" => &stats.plan_failures_fail_closed, + "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, @@ -862,15 +1057,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/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 d7edb851..74ffc100 100644 --- a/src/filter/engine.rs +++ b/src/filter/engine.rs @@ -16,12 +16,13 @@ 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_COMMIT, XLOG_XACT_COMMIT_PREPARED, - XLOG_XACT_INVALIDATIONS, XLOG_XACT_OPMASK, XactPayloadError, parse_xact_invalidations, - parse_xact_payload, + 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::{AffectedOid, BoundaryInfo, Route, rmgr_label}; @@ -110,18 +111,10 @@ pub struct Verdict { pub catalog_boundary: bool, /// Capture input; `Some` iff `catalog_boundary` pub boundary: Option>, -} - -/// One catalog-dirty xact's accumulated capture inputs, keyed by the -/// writing xid (top or sub); merged across the tree at its commit. -#[derive(Debug)] -struct DirtyXact { - /// First catalog-touching record LSN under this xid - first_touch: u64, - /// User oid → first pg_class touch LSN under this xid - oids: HashMap, - /// Wrote a capture-all catalog (pg_namespace) - unenumerated: bool, + /// 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 @@ -165,11 +158,11 @@ impl SmgrMarkers { pub struct Filter { tracker: CatalogTracker, stats: FilterStats, - /// Xids (top or sub) that wrote a catalog-touching record or logged a - /// descriptor-relevant `XLOG_XACT_INVALIDATIONS` set; drained at their - /// commit / abort. Crash-orphaned xids linger, bounded by workload (no - /// commit ever arrives to hold on) - catalog_dirty: HashMap, + /// 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 @@ -181,7 +174,7 @@ impl Filter { Self { tracker: CatalogTracker::new(), stats: FilterStats::default(), - catalog_dirty: HashMap::new(), + dirty: DirtyTree::default(), smgr_markers: Arc::new(Mutex::new(SmgrMarkers::default())), inval_db_oid: None, } @@ -252,13 +245,13 @@ impl Filter { // 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, true), + 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, true) + (Route::ToShadow, catalog_mutation(record)) } else { (Route::ToDecoder, false) } @@ -285,12 +278,12 @@ impl Filter { .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.catalog_dirty.entry(xid).or_insert_with(|| DirtyXact { - first_touch: source_lsn, - oids: HashMap::new(), - unenumerated: false, - }); + 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); } @@ -301,6 +294,7 @@ impl Filter { 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); @@ -308,6 +302,7 @@ impl Filter { route, catalog_boundary: boundary.is_some(), boundary, + defer_catalog_decode, }) } @@ -315,7 +310,8 @@ impl Filter { /// 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`), so no ASSIGNMENT tracking is needed. + /// (`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( @@ -333,6 +329,17 @@ impl Filter { 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 { @@ -340,30 +347,9 @@ impl Filter { } let payload = parse_xact_payload(info, &record.main_data, page_magic)?; let header_xid = record.header.xact_id; - let mut merged: Option = None; - let mut absorb = |dirty: Option| { - let Some(dirty) = dirty else { return }; - match &mut merged { - None => merged = Some(dirty), - Some(m) => { - m.first_touch = m.first_touch.min(dirty.first_touch); - m.unenumerated |= dirty.unenumerated; - for (oid, lsn) in dirty.oids { - m.oids - .entry(oid) - .and_modify(|l| *l = (*l).min(lsn)) - .or_insert(lsn); - } - } - } - }; - absorb(self.catalog_dirty.remove(&header_xid)); - if let Some(x) = payload.twophase_xid { - absorb(self.catalog_dirty.remove(&x)); - } - for x in &payload.subxacts { - absorb(self.catalog_dirty.remove(x)); - } + let merged = self + .dirty + .drain_tree(header_xid, payload.twophase_xid, &payload.subxacts); if !is_commit { return Ok(None); } @@ -393,15 +379,11 @@ impl Filter { if !dirty_hit && inval_oids.is_empty() && !capture_all { return Ok(None); } - let mut merged = merged.unwrap_or_else(|| DirtyXact { - // 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) - first_touch: source_lsn, - oids: HashMap::new(), - unenumerated: false, - }); + // 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(); } @@ -455,11 +437,7 @@ impl Filter { if !namespace_hit && !flush && oids.is_empty() { return Ok(()); } - let dirty = self.catalog_dirty.entry(xid).or_insert_with(|| DirtyXact { - first_touch: source_lsn, - oids: HashMap::new(), - unenumerated: false, - }); + 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 @@ -495,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::*; @@ -738,6 +741,137 @@ mod tests { 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(); 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/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 3b65c123..6f5a6841 100644 --- a/src/ops/metrics.rs +++ b/src/ops/metrics.rs @@ -87,9 +87,39 @@ 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; 6], + /// 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, + /// Ambiguous descriptor lookups by [`crate::catalog::desc_log::AmbiguityReason`] + /// order `[unknown_affected_relation, unknown_mutation_position, + /// multiple_incompatible_layouts, never_visible_generation, + /// incomplete_invalidation]`, rendered `reason=` labelled + pub descriptor_ambiguous_by_reason: [u64; 5], pub emitter_rows_total: u64, pub emitter_blocks_total: u64, pub emitter_xacts_total: u64, @@ -120,8 +150,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. @@ -164,6 +192,8 @@ pub struct MetricsSnapshot { 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, } @@ -254,6 +284,26 @@ 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` +const PLAN_FAILURE_REASONS: [&str; 6] = [ + "spool", + "fail_closed", + "detoast", + "partial_update", + "view", + "drain", +]; + +/// `reason=` label order of `MetricsSnapshot::descriptor_ambiguous_by_reason`, +/// matching [`crate::catalog::desc_log::AmbiguityReason`] variant order +const AMBIGUITY_REASONS: [&str; 5] = [ + "unknown_affected_relation", + "unknown_mutation_position", + "multiple_incompatible_layouts", + "never_visible_generation", + "incomplete_invalidation", +]; + pub fn render(snap: &MetricsSnapshot) -> String { use std::fmt::Write as _; let mut s = String::with_capacity(1024); @@ -482,6 +532,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.", @@ -496,10 +553,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", @@ -591,18 +649,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.", @@ -741,6 +787,18 @@ pub fn render(snap: &MetricsSnapshot) -> String { "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.", @@ -778,6 +836,130 @@ 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(); + let name = "walshadow_descriptor_ambiguous_total"; + writeln!( + s, + "# HELP {name} Descriptor lookups landing in an ambiguity interval." + ) + .unwrap(); + writeln!(s, "# TYPE {name} counter").unwrap(); + for (reason, v) in AMBIGUITY_REASONS + .iter() + .zip(snap.descriptor_ambiguous_by_reason) + { + writeln!(s, "{name}{{reason=\"{reason}\"}} {v}").unwrap(); + } + } + // Umbrella count bare + per-mode labelled series in one family { let name = "walshadow_config_backfills_pending"; @@ -934,6 +1116,72 @@ 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], + 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")); + assert!(body.contains("walshadow_xact_plan_failures_total{reason=\"spool\"} 1")); + assert!(body.contains("walshadow_xact_plan_failures_total{reason=\"fail_closed\"} 2")); + assert!(body.contains("walshadow_xact_plan_failures_total{reason=\"detoast\"} 3")); + assert!(body.contains("walshadow_xact_plan_failures_total{reason=\"partial_update\"} 4")); + assert!(body.contains("walshadow_xact_plan_failures_total{reason=\"view\"} 5")); + assert!(body.contains("walshadow_xact_plan_failures_total{reason=\"drain\"} 6")); + assert!(body.contains("walshadow_route_snapshots_total{result=\"mapped\"} 7")); + assert!(body.contains("walshadow_route_snapshots_total{result=\"unmapped\"} 8")); + } + + #[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_by_reason: [21, 22, 23, 24, 25], + ..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")); + assert!(body.contains( + "walshadow_descriptor_ambiguous_total{reason=\"unknown_affected_relation\"} 21" + )); + assert!(body.contains( + "walshadow_descriptor_ambiguous_total{reason=\"incomplete_invalidation\"} 25" + )); + } + #[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/record.rs b/src/record.rs index 09773927..ba42d528 100644 --- a/src/record.rs +++ b/src/record.rs @@ -106,6 +106,11 @@ pub struct Record<'a> { 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 { @@ -194,6 +199,7 @@ impl RecordSink for CollectingRecordSink { route: record.route, catalog_boundary: record.catalog_boundary, boundary_info: record.boundary_info.clone(), + defer_catalog_decode: record.defer_catalog_decode, }); Ok(()) }) diff --git a/src/source/catalog_capture.rs b/src/source/catalog_capture.rs index 4f5a8c0a..48d12d8d 100644 --- a/src/source/catalog_capture.rs +++ b/src/source/catalog_capture.rs @@ -19,6 +19,14 @@ //! `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 only when the final descriptor provably reads the whole +//! dirty interval (`catalog::compat`). An 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. 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; @@ -26,7 +34,10 @@ use std::sync::Arc; use tokio_postgres::types::Oid; use walrus::pg::walparser::RelFileNode; -use crate::catalog::desc_log::{BatchRecord, DescriptorLog, LogEntry, LogValue}; +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}; @@ -49,6 +60,8 @@ crate::atomic_stats! { 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, } @@ -222,7 +235,32 @@ impl CatalogCapture { .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); @@ -237,19 +275,29 @@ impl CatalogCapture { // 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(); - let valid_from = if rotated || fresh { + // 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) - .or_else(|| pg_class_touch.get(&oid).copied()) - .unwrap_or(info.tree_first_touch) } else { - pg_class_touch - .get(&oid) - .copied() - .unwrap_or(info.tree_first_touch) + 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, + valid_from: first_touch, oid, rfn: old.rfn, value: LogValue::Retired, @@ -257,6 +305,29 @@ impl CatalogCapture { } 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, ambiguity) = + in_place_verdict(pred, desc, first_touch, next_lsn); + valid_from = from; + if let Some((amb, why)) = ambiguity { + tracing::warn!( + target: "walshadow::desc_log", + oid, + rel = %desc.rel_name, + from = format_args!("{:#X}", amb.from_lsn), + through = format_args!("{:#X}", amb.through_lsn), + why, + "in-place change not provably decodable, ambiguity published", + ); + ambiguities.push(Arc::new(amb)); + self.stats.ambiguities_published.fetch_add(1, Relaxed); + } + } let desc = Arc::new(desc.clone()); entries.push(Arc::new(LogEntry { valid_from, @@ -290,11 +361,22 @@ impl CatalogCapture { } } } + 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 @@ -309,6 +391,35 @@ impl CatalogCapture { } } +/// Entry LSN for an in-place final version + the ambiguity 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 +fn in_place_verdict( + pred: &RelDescriptor, + fin: &RelDescriptor, + first_touch: u64, + next_lsn: u64, +) -> (u64, Option<(Ambiguity, &'static str)>) { + match crate::catalog::compat::compatible_reader(pred, fin) { + Ok(()) => (first_touch, None), + Err(why) => ( + next_lsn, + Some(( + Ambiguity { + scope: AmbiguityScope::Rfn(fin.rfn), + from_lsn: first_touch, + through_lsn: next_lsn, + reason: AmbiguityReason::UnknownMutationPosition, + }, + why, + )), + ), + } +} + /// 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 { @@ -327,3 +438,63 @@ fn diff_event(pred: Option<&RelDescriptor>, desc: &Arc) -> Option } } } + +#[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, amb) = in_place_verdict(&pred, &fin, 100, 500); + assert_eq!(from, 100); + assert!(amb.is_none()); + } + + #[test] + fn incompatible_in_place_publishes_interval() { + let pred = rel(23, 4, "t"); + let fin = rel(20, 8, "t"); + let (from, amb) = in_place_verdict(&pred, &fin, 100, 500); + assert_eq!(from, 500, "final version serves post-commit rows only"); + let (amb, why) = amb.expect("ambiguity for type change"); + assert_eq!(amb.scope, AmbiguityScope::Rfn(fin.rfn)); + assert_eq!(amb.from_lsn, 100); + assert_eq!(amb.through_lsn, 500); + assert_eq!(amb.reason, AmbiguityReason::UnknownMutationPosition); + assert!(!why.is_empty()); + } +} diff --git a/src/source/queueing_record_sink.rs b/src/source/queueing_record_sink.rs index fd62afba..27690dd1 100644 --- a/src/source/queueing_record_sink.rs +++ b/src/source/queueing_record_sink.rs @@ -347,6 +347,7 @@ impl RecordSink for QueueingRecordSink { route: record.route, catalog_boundary: record.catalog_boundary, boundary_info: record.boundary_info.clone(), + defer_catalog_decode: record.defer_catalog_decode, }); if self.buf.len() >= self.batch_size { self.flush_buf().await?; diff --git a/src/source/wal_stream.rs b/src/source/wal_stream.rs index 3c5ebce2..a1604379 100644 --- a/src/source/wal_stream.rs +++ b/src/source/wal_stream.rs @@ -327,6 +327,7 @@ impl WalStream { route, catalog_boundary: verdict.catalog_boundary, boundary_info: verdict.boundary, + defer_catalog_decode: verdict.defer_catalog_decode, }; if let Some(sink) = record_sink.as_deref_mut() { sink.on_record(&record).await?; diff --git a/src/xact/spill.rs b/src/xact/spill.rs index 62c8e9ca..1e5ba529 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() @@ -238,11 +256,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 +297,7 @@ impl SpillStore { file, path, byte_count: header.len() as u64, + dict: std::collections::HashMap::new(), }) } @@ -457,6 +478,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 +493,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 +531,7 @@ impl SpillWriter { file, path: self.path, header_checked: false, + dict: Vec::new(), }) } @@ -514,6 +541,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 +569,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 +607,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) + 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()), } - 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)) - } - 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 +710,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 +891,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 +927,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 +980,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 +1157,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 +1183,7 @@ fn decode_raw(c: &mut Cursor) -> Result { }); } Ok(RawRecord { + xid, rm, info, source_lsn, @@ -1126,32 +1198,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 +1292,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 +1349,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 +1379,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); @@ -1412,6 +1525,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 +1536,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 +1572,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 +1677,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 +1695,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 733d6489..ba1b0b57 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. //! @@ -37,7 +37,7 @@ use std::cmp::Reverse; use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -49,7 +49,7 @@ use walrus::pg::walparser::{RelFileNode, RmId}; use crate::catalog::desc_log::{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::{ @@ -142,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, @@ -224,6 +224,63 @@ 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 + #[error("ordinary raw decode for rel {relid} at {lsn:#X} failed closed: {reason}")] + OrdinaryFailClosed { + relid: u32, + lsn: u64, + reason: FailClosedReason, + }, + /// Stashed filenode resolved 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} ambiguous at {lsn:#X}: {reason:?}")] + StashAmbiguous { + rel_node: u32, + lsn: u64, + reason: crate::catalog::desc_log::AmbiguityReason, + }, + /// 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 }, +} + +/// 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 { @@ -270,6 +327,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 { @@ -369,11 +430,13 @@ impl XactState { 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 @@ -447,9 +510,10 @@ 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(Arc, u64), } /// Resolution map for a finishing tree; filenodes absent from `outcomes` @@ -487,8 +551,8 @@ pub async fn resolve_stash( let mut outcomes: HashMap = HashMap::new(); let mut barriers: Vec<(u32, u64)> = Vec::new(); for rfn in &rfns { - match log.descriptor_at(*rfn, next_lsn) { - LookupResult::Present(rel) if rel.kind == 't' => { + match log.descriptor_at_spanned(*rfn, next_lsn) { + Ok((rel, _)) if rel.kind == 't' => { let marker = buffer.lock().await.marker_lsn(*rfn); let Some(marker_lsn) = marker else { return Err(XactBufferError::IncompleteToastGeneration { relid: rel.oid }); @@ -496,17 +560,32 @@ pub async fn resolve_stash( barriers.push((rel.oid, marker_lsn)); outcomes.insert(*rfn, StashOutcome::Toast(rel)); } - LookupResult::Present(_) => { - outcomes.insert(*rfn, StashOutcome::Skip); + Ok((rel, valid_from)) => { + outcomes.insert(*rfn, StashOutcome::Ordinary(rel, valid_from)); + } + // No descriptor proven safe for the stashed rows; dropping them + // would be silent row loss, decoding them a corruption risk + Err(LookupResult::Ambiguous(a)) => { + return Err(XactBufferError::StashAmbiguous { + rel_node: rfn.rel_node, + lsn: next_lsn, + reason: a.reason, + }); } // Dropped / rotated away by this xid or a later covered commit; - // AEL supersession makes the discard end-state-neutral. Foreign - // db never stashes rows worth keeping; NotCovered = rel never - // reached the log (born + gone inside this xact's family) - LookupResult::Dropped - | LookupResult::Retired - | LookupResult::NotCovered - | LookupResult::ForeignDb => {} + // 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); + } + Err(LookupResult::Present(_)) => unreachable!("spanned lookup returns Present as Ok"), } } let mut buf = buffer.lock().await; @@ -567,6 +646,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 { @@ -589,6 +672,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)), }) } @@ -616,6 +701,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. @@ -632,12 +726,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 } @@ -658,8 +760,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; @@ -705,15 +807,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 } @@ -889,6 +991,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 @@ -896,10 +999,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?; @@ -962,6 +1067,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()); @@ -990,6 +1096,8 @@ impl XactBuffer { Ok(MergedDrain { sources, events, + allowed_xids, + pending_heaps: VecDeque::new(), chunks: ChunkRefMap::new(), chunk_bytes: 0, collect_rows, @@ -999,6 +1107,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, @@ -1066,8 +1180,16 @@ impl XactBuffer { } self.stats.bytes_in_memory = self.bytes_in_memory as u64; let stash = self.pending_stash.remove(&top_xid).unwrap_or_default(); + 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 { @@ -1213,6 +1335,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 @@ -1279,7 +1434,7 @@ impl MergeSource { } enum MergeItem { - Heap(Box), + Heap(Box), Event(DrainEntry), } @@ -1308,6 +1463,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, @@ -1317,6 +1478,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 @@ -1324,6 +1489,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, @@ -1343,6 +1511,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), @@ -1376,7 +1553,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)?, @@ -1386,6 +1566,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> { @@ -1463,8 +1653,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(()); @@ -1476,9 +1667,9 @@ 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)) => { + let (rel, valid_from) = (rel.clone(), *valid_from); + return self.fold_raw_ordinary(raw, rel, valid_from); } None => { bump(&|s| &s.toast_stash_discarded); @@ -1486,7 +1677,13 @@ impl MergedDrain { } }; 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)?, @@ -1495,6 +1692,100 @@ 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 + fn fold_raw_ordinary( + &mut self, + raw: &RawRecord, + rel: Arc, + valid_from: u64, + ) -> 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, + reason, + }; + 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> { @@ -1639,7 +1930,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 @@ -1665,8 +1956,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 @@ -1702,7 +1993,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"); @@ -1754,7 +2045,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; @@ -1767,7 +2058,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(); @@ -1815,19 +2106,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], - log: &DescriptorLog, 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); } @@ -1839,18 +2129,9 @@ pub async fn detoast_heap( Some(b) => Some(b.acquire(leaf_need).await), None => None, }; - // A heap reaching detoast already decoded once against a covered - // descriptor; anything but Present here is a coverage bug - let rel: Arc = match log.descriptor_at(heap.rfn, heap.source_lsn) { - LookupResult::Present(rel) => rel, - other => { - return Err(XactBufferError::DescriptorNotCovered { - rfn: heap.rfn, - lsn: heap.source_lsn, - got: format!("{other:?}"), - }); - } - }; + // 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; @@ -1859,15 +2140,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() { @@ -2167,10 +2448,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 @@ -2197,14 +2480,12 @@ impl BufferingDecoderSink { for relid in parsed.relids { // Same-xact CREATE + TRUNCATE: the rel's Added has no batch yet // (capture runs at commit) → NotCovered, nothing lives to wipe - let rel = match self.log.descriptor_by_oid_at(relid, source_lsn) { - LookupResult::Present(r) => r, - _ => { - self.stats - .catalog_not_found - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - continue; - } + 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; }; // CH has no per-table internal toast; only user heap // ('r'/'p') TRUNCATE propagates @@ -2221,7 +2502,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(()) } @@ -2274,6 +2561,25 @@ impl RecordSink for BufferingDecoderSink { return Ok(()); }; 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 raw = crate::xact::spill::RawRecord::from_parsed( + &record.parsed, + record.source_lsn, + record.page_magic, + ); + 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 the log has no entry yet either @@ -2292,17 +2598,21 @@ impl RecordSink for BufferingDecoderSink { // 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 = match self.log.descriptor_at(rfn, record.source_lsn) { - LookupResult::Present(rel) => rel, + 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 - LookupResult::ForeignDb => { + Err(LookupResult::ForeignDb) => { self.stats .catalog_not_found .fetch_add(1, std::sync::atomic::Ordering::Relaxed); return Ok(()); } - LookupResult::NotCovered + Err(LookupResult::NotCovered) if record.source_lsn <= self.log.covered_through() || txn_xid == 0 => { self.stats @@ -2312,14 +2622,18 @@ impl RecordSink for BufferingDecoderSink { } // Filenode invisible at record LSN: created by this // still-open xact (same-xact CREATE / TRUNCATE / rewrite - // generation) or already superseded — resolve at commit - LookupResult::NotCovered | LookupResult::Dropped => { + // generation) or already superseded — resolve at commit. + // Ambiguous defers the same way: commit-time resolution + // decides under the post-boundary descriptor state + Err( + LookupResult::NotCovered | LookupResult::Dropped | LookupResult::Ambiguous(_), + ) => { 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 - LookupResult::Retired => { + Err(LookupResult::Retired) => { self.stats .catalog_not_found .fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -2430,7 +2744,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>(()) @@ -2606,6 +2926,110 @@ 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>, + ) { + let mut outcomes = HashMap::new(); + outcomes.insert(rfn, StashOutcome::Ordinary(rel, 0x50)); + b.pending_stash + .insert(1, StashResolution { outcomes, stats }); + } +} + #[cfg(test)] mod tests { //! Catalog-free paths only. Commit-drain + detoast + @@ -2613,6 +3037,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; @@ -2670,21 +3095,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, + }], } } @@ -3487,7 +3969,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)); @@ -3518,6 +4000,350 @@ mod tests { 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}" + ); + } + + /// 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. @@ -3640,8 +4466,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/bootstrap_pipeline_ch.rs b/tests/bootstrap_pipeline_ch.rs index 56102060..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}; @@ -143,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)); @@ -182,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 19c90658..d498a8d2 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}; @@ -537,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 { @@ -724,6 +725,9 @@ async fn build_pipeline_inner( .seed( walshadow::desc_log::BatchRecord { captured_at: covered_through, + commit_lsn: 0, + observations: Vec::new(), + ambiguities: Vec::new(), entries, }, covered_through, @@ -735,7 +739,7 @@ async fn build_pipeline_inner( 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. @@ -831,7 +835,7 @@ async fn build_pipeline_inner( decoder = decoder.with_config_schema(Arc::from(schema)); } let capture = walshadow::catalog_capture::CatalogCapture::new( - desc_log, + desc_log.clone(), catalog.clone(), xact_buffer.clone(), smgr_markers, @@ -858,6 +862,7 @@ async fn build_pipeline_inner( ack: emitter_ack, resume_floor, stats, + desc_log, } } @@ -939,6 +944,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 index c6852ac0..423d9abf 100644 --- a/tests/desc_log_e2e.rs +++ b/tests/desc_log_e2e.rs @@ -7,12 +7,11 @@ #[path = "common/inproc_harness.rs"] mod fx; -use std::process::{Command, Stdio}; +use fx::spawn_txn; use std::time::Duration; use walshadow::mapping::{ColumnMapping, TableTarget}; use walshadow::schema::RelName; -use walshadow::shadow::Shadow; const SLOT_PREPARED: PortSlot = PortSlot { source: 17960, @@ -28,6 +27,13 @@ const SLOT_RENAME: PortSlot = PortSlot { 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, @@ -37,51 +43,14 @@ struct PortSlot { walsender: u16, } -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(); - }) -} - -/// Live 2PC: DDL + DML inside a prepared xact reach CH at COMMIT PREPARED. -/// The commit record's header xid is the finishing backend's; both the -/// capture-keyed events and the buffered rows live under the prepared xid -/// (B2) — pre-fix the drain keyed header xid and stranded them. The table -/// pre-exists (same-xact CREATE + INSERT rows stay fenced — stash item 5, -/// out of scope here). +/// 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() { @@ -156,15 +125,23 @@ async fn prepared_ddl_drains_at_commit_prepared() { .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", - "prepared xact rows drain at COMMIT PREPARED", + "catalog-dirty rows deliver via raw decode", ) .await; fx::wait_query( @@ -284,3 +261,148 @@ async fn schema_rename_reroutes_under_new_namespace() { ) .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", + 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 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\ + 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(); + + // Unproven widen: one rfn-scoped interval, Ambiguous inside, final + // version at through_lsn (= boundary next_lsn), predecessor before + let ambs = log.rfn_ambiguities(rfn_wid); + assert_eq!(ambs.len(), 1, "widen publishes one ambiguity: {ambs:?}"); + let amb = &ambs[0]; + assert_eq!(amb.reason, AmbiguityReason::UnknownMutationPosition); + assert!(amb.from_lsn < amb.through_lsn); + assert!(matches!( + log.descriptor_at(rfn_wid, amb.from_lsn), + LookupResult::Ambiguous(_) + )); + assert!(matches!( + log.descriptor_at(rfn_wid, amb.through_lsn - 1), + LookupResult::Ambiguous(_) + )); + match log.descriptor_at(rfn_wid, amb.through_lsn) { + // varchar(20) typmod = 24 + LookupResult::Present(d) => assert_eq!(d.attributes[1].typmod, 24), + other => panic!("expected final Present at through_lsn, got {other:?}"), + } + let pred = log + .present_before(rfn_wid, amb.from_lsn) + .expect("durable predecessor"); + assert_eq!(pred.attributes[1].typmod, 14, "varchar(10) 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 index c555fcec..ffd8d475 100644 --- a/tests/desc_log_restart_e2e.rs +++ b/tests/desc_log_restart_e2e.rs @@ -11,6 +11,7 @@ #[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; @@ -108,45 +109,6 @@ fn wait_idle_in_txn(source: &Shadow) { } } -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 create_dest_table(ch: &fx::ChServer, table: &str) { ch.query("CREATE DATABASE IF NOT EXISTS walshadow_test") .expect("create db"); diff --git a/tests/dirty_admission_e2e.rs b/tests/dirty_admission_e2e.rs new file mode 100644 index 00000000..1c12f85a --- /dev/null +++ b/tests/dirty_admission_e2e.rs @@ -0,0 +1,526 @@ +//! 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, +}; + +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", + ); +} + +/// 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 1f23fff8..ba13adc3 100644 --- a/tests/emitter_budget_flush.rs +++ b/tests/emitter_budget_flush.rs @@ -163,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); @@ -172,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 c6ec4c1c..79f6b8c9 100644 --- a/tests/emitter_native_types.rs +++ b/tests/emitter_native_types.rs @@ -165,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 55e02b93..42633879 100644 --- a/tests/emitter_tls.rs +++ b/tests/emitter_tls.rs @@ -391,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 a5d5febb..637245d2 100644 --- a/tests/multi_segment_filter.rs +++ b/tests/multi_segment_filter.rs @@ -327,6 +327,7 @@ impl RecordSink for SharedCollectingSink { route: r.route, catalog_boundary: r.catalog_boundary, boundary_info: r.boundary_info.clone(), + 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/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 372af5c8..1ff7267b 100644 --- a/tests/wal_stream_throughput.rs +++ b/tests/wal_stream_throughput.rs @@ -325,6 +325,7 @@ async fn pump_throughput_breakdown() { route: r.route, catalog_boundary: r.catalog_boundary, boundary_info: r.boundary_info.clone(), + 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 3b70bdc2..2831329d 100644 --- a/tests/xact_buffer.rs +++ b/tests/xact_buffer.rs @@ -24,7 +24,7 @@ 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}; @@ -144,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, @@ -184,6 +196,9 @@ async fn log_from_catalog(cat: &Arc>, dir: &std::path::Path log.seed( BatchRecord { captured_at: 1, + commit_lsn: 0, + observations: Vec::new(), + ambiguities: Vec::new(), entries, }, 1, @@ -197,7 +212,6 @@ async fn log_from_catalog(cat: &Arc>, dir: &std::path::Path /// per heap step, collecting [`CommittedTuple`]s in walk order. async fn drain_all( b: &mut XactBuffer, - log: &DescriptorLog, resolver: &ToastResolver, xid: u32, commit_ts: i64, @@ -220,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, log, 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, }); @@ -274,17 +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 log = log_from_catalog(&cat, tmp.path()).await; - let seen = drain_all(&mut b, &log, &ToastResolver::disabled(), 7, 12345, 300, &[]) + 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); @@ -300,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 { @@ -342,20 +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 log = log_from_catalog(&cat, tmp.path()).await; - let seen = drain_all(&mut b, &log, &ToastResolver::disabled(), 5, 0, 250, &[]) + let seen = drain_all(&mut b, &ToastResolver::disabled(), 5, 0, 250, &[]) .await .unwrap(); assert_eq!(seen.len(), 5); @@ -392,27 +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 log = log_from_catalog(&cat, tmp.path()).await; - let seen = drain_all( - &mut b, - &log, - &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. @@ -462,21 +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 log = log_from_catalog(&cat, tmp.path()).await; - let seen = drain_all( - &mut b, + b.on_heap(described( &log, - &ToastResolver::disabled(), - 33, - 12345, - 300, - &[], - ) + 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 { @@ -522,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 @@ -532,8 +633,7 @@ async fn detoast_missing_chunk_seq_errors_clearly() { Arc::new(MemChunkStore::new()), Arc::new(EmitterStats::default()), ); - let log = log_from_catalog(&cat, tmp.path()).await; - let err = drain_all(&mut b, &log, &resolver, 42, 0, 200, &[]) + let err = drain_all(&mut b, &resolver, 42, 0, 200, &[]) .await .expect_err("missing chunk surfaces"); match err { @@ -552,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![ @@ -563,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(); From b01e97617e7e26b4fb8d864623efde70d335a6cc Mon Sep 17 00:00:00 2001 From: serprex <159546+serprex@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:45:12 +0000 Subject: [PATCH 4/4] Fence stashed rows per record when a layout change isn't provably readable Capture marks span it can't prove decodable when a transaction changes a table in place. Stash resolution asks log at the commit's next_lsn, past that span's end, so it always got a clean Present back and rows written inside the span decoded under the final descriptor anyway Only mark a span when bytes really change. typmod and attstorage moves are declared shape only. Tuple walk reads attlen/attalign/attbyval, so keeps early valid_from and publish nothing. Publish span per key verdict can name, filenode and oid, so decode and truncate see same fence Close the neighbouring holes the same way. A filenode landing inside a span with no XLOG_SMGR_CREATE marker fails closed instead of being tracked without payload and silently dropped at commit. Draining stashed records with no verdict installed fails closed instead of sending every one through the discard arm. An aborted tree's verdict is removed so the next transaction reusing that xid can't fold rows under it Stashed records drop block images on blocks that also carry data, without this a dirty COPY spills its whole WAL volume Run descriptor log compaction on its own task, fed each persisted floor over a watch channel. Inline on the pump it rewrote the checkpoint while no keepalive was answered, long enough to hit the source's wal_sender_timeout. Tail-bytes gauge reads a mirror so it no longer dips to zero for the length of a compaction Split plan-failure counter by cause so an unmodelled WAL op is distinguishable from spill error --- plans/desc_log.md | 76 ++- plans/future/INDEX.md | 1 + plans/future/catalog_capture_completeness.md | 9 + plans/future/descriptor_timeline.md | 84 ++++ plans/xact.md | 36 +- src/bin/stream.rs | 69 ++- src/catalog/compat.rs | 158 +++++-- src/catalog/desc_log.rs | 296 +++++++++--- src/emit/ch_emitter.rs | 7 +- src/emit/pipeline/planner.rs | 70 ++- src/emit/pipeline/reorder.rs | 7 +- src/filter/catalog_tracker.rs | 10 +- src/ops/metrics.rs | 93 ++-- src/source/catalog_capture.rs | 128 +++-- src/xact/spill.rs | 67 +++ src/xact/xact_buffer.rs | 472 +++++++++++++++++-- tests/common/inproc_harness.rs | 47 +- tests/desc_log_e2e.rs | 68 ++- tests/dirty_admission_e2e.rs | 122 +++++ 19 files changed, 1510 insertions(+), 310 deletions(-) create mode 100644 plans/future/descriptor_timeline.md diff --git a/plans/desc_log.md b/plans/desc_log.md index 0083cab9..8dddcf7a 100644 --- a/plans/desc_log.md +++ b/plans/desc_log.md @@ -72,20 +72,37 @@ 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: -metadata-only changes (renames, defaults) and appended nullable / -missing-value columns qualify; physical changes — type, typmod, typlen, -alignment, attnum reuse, not-null append without a missing value — do -not. An incompatible in-place change (same rfn, no rotation) instead -publishes an `Ambiguity` interval `[first_touch, next_lsn)` alongside -the final `Present`: within it no single descriptor provably decodes the -rfn's rows. Scope is `Rfn`, `Oid`, or conservatively `Database` when -affected relations can't be enumerated; reasons enumerate -`UnknownAffectedRelation / UnknownMutationPosition / -MultipleIncompatibleLayouts / NeverVisibleGeneration / -IncompleteInvalidation`. Ambiguities are batch records like entries — +([`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. @@ -127,13 +144,21 @@ 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/Ambiguous with a - live xid stash for commit-time resolution ([xact.md](xact.md) - Commit-time stash); Retired skips (rows can't outlive the rotation) + 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, Ambiguous → fatal fail-closed, - tombstones discard + 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 @@ -152,12 +177,17 @@ 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 after each manifest persist against the same resolved floor -([ops.md](ops.md)): 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. Thresholds: ≥512 droppable entries or an 8 MiB tail. +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 diff --git a/plans/future/INDEX.md b/plans/future/INDEX.md index 1ae6a350..75151722 100644 --- a/plans/future/INDEX.md +++ b/plans/future/INDEX.md @@ -5,6 +5,7 @@ surface; promote into `plans/` once built * [TABLESPACES.md](TABLESPACES.md) — source-tablespace correctness: bootstrap page-walk + shadow directory materialization; full-rfn physical identity invariant * [catalog_capture_completeness.md](catalog_capture_completeness.md) — catalog → descriptor dependency taxonomy: capture-all trigger set, pg_type/typname staleness, rename events, toast-spool retire on rotation +* [descriptor_timeline.md](descriptor_timeline.md) — per-record layout fidelity inside a dirty xact: hold at `XLOG_XACT_INVALIDATIONS`, shadow-side historic read via pgext, per-command descriptor timeline replacing the commit-time sample * [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 diff --git a/plans/future/catalog_capture_completeness.md b/plans/future/catalog_capture_completeness.md index 1940eb82..c8a13bb8 100644 --- a/plans/future/catalog_capture_completeness.md +++ b/plans/future/catalog_capture_completeness.md @@ -47,6 +47,15 @@ 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 — 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/xact.md b/plans/xact.md index ab93df3d..e8ed5e5a 100644 --- a/plans/xact.md +++ b/plans/xact.md @@ -42,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 } ``` @@ -116,15 +116,23 @@ abort discard come for free. Three admission gates, in order: ([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 + 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, Dropped, or Ambiguous defers - to commit-time resolution; ForeignDb / pre-horizon NotCovered / - Retired are counted skips (rows can't outlive the commit) + 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 @@ -135,15 +143,23 @@ verdicts the drain merge consumes: 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)`: raw records re-run - the heap decoder at drain under the commit-resolution descriptor, +- 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 -- Ambiguous → fatal `XactBufferError::StashAmbiguous`: no descriptor - proven safe, neither decode nor discard is sound; operator takes a - fresh snapshot +- `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 diff --git a/src/bin/stream.rs b/src/bin/stream.rs index 94571ccc..38777a24 100644 --- a/src/bin/stream.rs +++ b/src/bin/stream.rs @@ -1514,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 @@ -1667,11 +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 - desc_log - .maybe_gc(cur.floor.0) - .await - .context("descriptor log gc")?; + // 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 @@ -1745,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) = { @@ -1900,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; @@ -2201,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)` @@ -2452,7 +2489,16 @@ async fn populate_metrics( .map(|s| { [ s.plan_failures_spool.load(Ordering::Relaxed), - s.plan_failures_fail_closed.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), @@ -2489,17 +2535,6 @@ async fn populate_metrics( .unwrap_or_default(), raw_pending_rows: drain_resident.raw_pending_rows, raw_pending_bytes: drain_resident.raw_pending_bytes, - descriptor_ambiguous_by_reason: [ - log_stats.ambiguous_unknown_relation.load(Ordering::Relaxed), - log_stats.ambiguous_unknown_position.load(Ordering::Relaxed), - log_stats - .ambiguous_incompatible_layouts - .load(Ordering::Relaxed), - log_stats.ambiguous_never_visible.load(Ordering::Relaxed), - log_stats - .ambiguous_incomplete_invalidation - .load(Ordering::Relaxed), - ], emitter_rows_total: emitter_stats .map(|s| s.rows_emitted.load(Ordering::Relaxed)) .unwrap_or(0), diff --git a/src/catalog/compat.rs b/src/catalog/compat.rs index 0ebb26e0..f267ae4d 100644 --- a/src/catalog/compat.rs +++ b/src/catalog/compat.rs @@ -1,83 +1,132 @@ //! Physical compatibility predicate: can the final committed descriptor //! decode tuples written earlier in the same dirty interval? //! -//! Bias rejects: capture publishes an ambiguity interval on any transition -//! not on the proven-safe list, never guesses. Compared fields are what -//! tuple walking + value interpretation read: attnum sequence, dropped -//! slots, attlen/attalign/attbyval, type oid + typmod + storage, +//! 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 -pub fn compatible_reader(old: &RelDescriptor, new: &RelDescriptor) -> Result<(), &'static str> { +/// `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("oid mismatch"); + return Err(Incompat::Physical("oid mismatch")); } if old.rfn != new.rfn { - return Err("filenode rotated"); + return Err(Incompat::Physical("filenode rotated")); } if old.kind != new.kind { - return Err("relkind change"); + return Err(Incompat::Physical("relkind change")); } if old.persistence != new.persistence { - return Err("persistence change"); + 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("toast relation change"); + return Err(Incompat::Physical("toast relation change")); } if new.attributes.len() < old.attributes.len() { - return Err("attribute truncation"); + return Err(Incompat::Physical("attribute truncation")); } + let mut benign: Option<&'static str> = None; for (o, n) in old.attributes.iter().zip(&new.attributes) { - slot_compatible(o, n)?; + 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("appended not-null column without missing value"); + return Err(Incompat::Physical( + "appended not-null column without missing value", + )); } } - Ok(()) + benign.map_or(Ok(()), |why| Err(Incompat::Benign(why))) } -fn slot_compatible(o: &RelAttr, n: &RelAttr) -> Result<(), &'static str> { +fn slot_compatible(o: &RelAttr, n: &RelAttr) -> Result<(), Incompat> { if o.attnum != n.attnum { - return Err("attnum sequence change"); + 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("physical walk fields change"); + 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("dropped slot resurrected"); + 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(()); } - if o.type_oid != n.type_oid || o.typmod != n.typmod { - return Err("type or typmod change"); - } - if o.type_storage != n.type_storage { - return Err("storage change"); + // 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("missing value change"); + 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(()) } @@ -173,20 +222,59 @@ mod tests { 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).is_err()); - let mut typmod = rel(vec![attr(1, 23, 4)]); - typmod.attributes[0].typmod = 12; - assert!(compatible_reader(&old, &typmod).is_err()); - let mut storage = rel(vec![attr(1, 23, 4)]); - storage.attributes[0].type_storage = 'e'; - assert!(compatible_reader(&old, &storage).is_err()); + 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!(compatible_reader(&old, &missing).is_err()); + assert_eq!( + compatible_reader(&old, &missing), + Err(Incompat::Physical("missing value change")) + ); let truncated = rel(vec![]); - assert!(compatible_reader(&old, &truncated).is_err()); + assert!( + compatible_reader(&old, &truncated) + .unwrap_err() + .is_physical() + ); let reorder = rel(vec![attr(2, 23, 4)]); - assert!(compatible_reader(&old, &reorder).is_err()); + 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] diff --git a/src/catalog/desc_log.rs b/src/catalog/desc_log.rs index b3466671..47be4df8 100644 --- a/src/catalog/desc_log.rs +++ b/src/catalog/desc_log.rs @@ -49,6 +49,14 @@ //! 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 @@ -57,13 +65,15 @@ //! the database's `dattablespace`, so stored rfns compare directly against //! WAL locators' physical spcOid. See `plans/future/TABLESPACES.md` §0. //! -//! Known scope debt (closed by the ordinary-heap stash fence lift, not -//! here): same-xact DDL+DML on an already-visible rfn stays timing-dependent -//! exactly as with the live-oracle path. +//! 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; @@ -78,7 +88,11 @@ pub const CKPT_FILE: &str = "desc_log.ckpt"; pub const TAIL_FILE: &str = "desc_log.tail"; const MAGIC: &[u8; 2] = b"WL"; -const VERSION: u16 = 2; +/// 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; @@ -162,13 +176,14 @@ pub enum AmbiguityScope { 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 { - UnknownAffectedRelation, - UnknownMutationPosition, - MultipleIncompatibleLayouts, - NeverVisibleGeneration, - IncompleteInvalidation, + /// 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)] @@ -253,6 +268,9 @@ struct Index { } 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 { @@ -313,12 +331,6 @@ crate::atomic_stats! { pub batches_appended, pub gc_runs, pub gc_dropped_entries, - /// `descriptor_ambiguous_total{reason}` split of `lookups_ambiguous` - pub ambiguous_unknown_relation, - pub ambiguous_unknown_position, - pub ambiguous_incompatible_layouts, - pub ambiguous_never_visible, - pub ambiguous_incomplete_invalidation, } } @@ -327,6 +339,8 @@ 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, } @@ -379,10 +393,18 @@ impl DescriptorLog { 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() } @@ -437,7 +459,10 @@ impl DescriptorLog { } let idx = self.index.read().unwrap(); // Ambiguity precedes the chain: a chain entry inside an ambiguous - // interval is not proven safe for rows there + // 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( @@ -488,35 +513,57 @@ impl DescriptorLog { 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(a) => { - self.stats.lookups_ambiguous.fetch_add(1, Relaxed); - let by_reason = match a.reason { - AmbiguityReason::UnknownAffectedRelation => { - &self.stats.ambiguous_unknown_relation - } - AmbiguityReason::UnknownMutationPosition => { - &self.stats.ambiguous_unknown_position - } - AmbiguityReason::MultipleIncompatibleLayouts => { - &self.stats.ambiguous_incompatible_layouts - } - AmbiguityReason::NeverVisibleGeneration => &self.stats.ambiguous_never_visible, - AmbiguityReason::IncompleteInvalidation => { - &self.stats.ambiguous_incomplete_invalidation - } - }; - by_reason.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. + /// 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)?; @@ -542,7 +589,9 @@ impl DescriptorLog { /// 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) + /// (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)?; @@ -557,6 +606,13 @@ impl DescriptorLog { 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 { @@ -573,7 +629,10 @@ impl DescriptorLog { .collect() } - /// Descriptors `Present` at `lsn` — boot Added enumeration + /// 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 @@ -639,6 +698,7 @@ impl DescriptorLog { 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); @@ -706,6 +766,7 @@ impl DescriptorLog { 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 @@ -714,27 +775,34 @@ impl DescriptorLog { Ok(()) } - /// (entries, bytes-in-tail, batches) gauges + /// (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); - let tail = self - .writer - .try_lock() - .map(|w| w.tail_len - w.header_len) - .unwrap_or(0); - (entries, tail, batches) + ( + entries, + self.tail_bytes.load(std::sync::atomic::Ordering::Relaxed), + batches, + ) } } -/// First interval covering `lsn` under `[from_lsn, through_lsn)`; lists stay -/// `(from_lsn, through_lsn)`-sorted so overlap resolution is deterministic +/// 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> { - list? + let list = list?; + let opened = list.partition_point(|a| a.from_lsn <= lsn); + list[..opened] .iter() - .find(|a| a.from_lsn <= lsn && lsn < a.through_lsn) + .rev() + .find(|a| lsn < a.through_lsn) .cloned() } @@ -1305,11 +1373,7 @@ fn decode_batch(cur: &mut Cur<'_>) -> Result { let from_lsn = cur.u64()?; let through_lsn = cur.u64()?; let reason = match cur.u8()? { - 0 => AmbiguityReason::UnknownAffectedRelation, 1 => AmbiguityReason::UnknownMutationPosition, - 2 => AmbiguityReason::MultipleIncompatibleLayouts, - 3 => AmbiguityReason::NeverVisibleGeneration, - 4 => AmbiguityReason::IncompleteInvalidation, other => return Err(cur.corrupt(format!("unknown ambiguity reason {other}"))), }; ambiguities.push(Arc::new(Ambiguity { @@ -2208,10 +2272,9 @@ mod tests { )); assert_eq!( log.stats_handle() - .ambiguous_unknown_position + .lookups_ambiguous .load(std::sync::atomic::Ordering::Relaxed), 1, - "reason-labelled counter", ); assert!(matches!( log.descriptor_at(rfn(9400), 100), @@ -2251,6 +2314,106 @@ mod tests { } } + #[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(); @@ -2262,17 +2425,24 @@ mod tests { .unwrap(); log.force_gc(0).await.unwrap(); } - // Pre-ambiguity v1 dir rejects at open, ckpt or tail alike; the - // explicit epoch reset (--ignore-cursor / re-bootstrap) is the only - // way forward, never silent translation + // 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(); - let mut bytes = orig.clone(); - bytes[2] = 1; // 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(1)), "{file}"); + 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; diff --git a/src/emit/ch_emitter.rs b/src/emit/ch_emitter.rs index 8ef8432f..34e1baff 100644 --- a/src/emit/ch_emitter.rs +++ b/src/emit/ch_emitter.rs @@ -1561,7 +1561,12 @@ crate::atomic_stats! { /// Planning-stage failures by reason; a failed plan means the whole /// transaction emits nothing pub plan_failures_spool, - pub plan_failures_fail_closed, + 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, diff --git a/src/emit/pipeline/planner.rs b/src/emit/pipeline/planner.rs index 0532a01c..565981dd 100644 --- a/src/emit/pipeline/planner.rs +++ b/src/emit/pipeline/planner.rs @@ -17,9 +17,12 @@ //! //! Validation coverage, one enforcement point per plan-success guarantee: //! - descriptor Present and unambiguous → commit-time stash verdicts at -//! the drain (ambiguous / never-visible generations fail closed) +//! 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`](crate::xact::xact_buffer::FailClosedReason)) +//! ([`FailClosedReason`]) //! - decoded xid matches owning xact/subxact → merge ownership check //! (`XactBufferError::ForeignXid`) //! - partial update reconstructs → [`PlanError::PartialUpdate`] here; no @@ -44,7 +47,7 @@ use crate::emit::pipeline::plan_spool::{ use crate::emit::route::RouteSnapshot; use crate::toast::{ChunkRefMap, ToastResolver}; use crate::xact::xact_buffer::{ - DrainEntry, DrainWalk, DrainedBatch, WalkStep, XactBufferError, detoast_heap, + DrainEntry, DrainWalk, DrainedBatch, FailClosedReason, WalkStep, XactBufferError, detoast_heap, }; /// Planner's window onto route state. Implementations resolve from frozen @@ -88,10 +91,22 @@ impl PlanError { } } -/// `reason=` label for drain-side errors aborting a plan +/// `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 { .. } => "fail_closed", + 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", } @@ -202,12 +217,51 @@ mod tests { "partial_update" ); assert_eq!(PlanError::View("x".into()).reason(), "view"); - let fail_closed = XactBufferError::OrdinaryFailClosed { + let fail_closed = |reason| XactBufferError::OrdinaryFailClosed { relid: 1, lsn: 2, - reason: FailClosedReason::ImageOnly, + rm: 10, + info: 0, + reason, }; - assert_eq!(drain_reason(&fail_closed), "fail_closed"); + 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" diff --git a/src/emit/pipeline/reorder.rs b/src/emit/pipeline/reorder.rs index 743fee03..eecd9fdf 100644 --- a/src/emit/pipeline/reorder.rs +++ b/src/emit/pipeline/reorder.rs @@ -917,7 +917,12 @@ impl ReorderSink { fn bump_plan_failure(stats: &EmitterStats, reason: &'static str) { let counter = match reason { "spool" => &stats.plan_failures_spool, - "fail_closed" => &stats.plan_failures_fail_closed, + "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, diff --git a/src/filter/catalog_tracker.rs b/src/filter/catalog_tracker.rs index 392b8310..5be55fd6 100644 --- a/src/filter/catalog_tracker.rs +++ b/src/filter/catalog_tracker.rs @@ -263,7 +263,15 @@ impl CatalogTracker { } /// True when `(db, rel)` is pg_namespace's current heap — the - /// capture-all trigger set + /// 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, diff --git a/src/ops/metrics.rs b/src/ops/metrics.rs index 6f5a6841..15ab2d36 100644 --- a/src/ops/metrics.rs +++ b/src/ops/metrics.rs @@ -97,7 +97,7 @@ pub struct MetricsSnapshot { 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; 6], + pub xact_plan_failures_by_reason: [u64; 11], /// Plan-time route resolutions `[mapped, unmapped]`, rendered /// `result=` labelled pub route_snapshots_by_result: [u64; 2], @@ -115,11 +115,6 @@ pub struct MetricsSnapshot { /// Gauges: raw-decoded heaps queued for pending-first yield pub raw_pending_rows: u64, pub raw_pending_bytes: u64, - /// Ambiguous descriptor lookups by [`crate::catalog::desc_log::AmbiguityReason`] - /// order `[unknown_affected_relation, unknown_mutation_position, - /// multiple_incompatible_layouts, never_visible_generation, - /// incomplete_invalidation]`, rendered `reason=` labelled - pub descriptor_ambiguous_by_reason: [u64; 5], pub emitter_rows_total: u64, pub emitter_blocks_total: u64, pub emitter_xacts_total: u64, @@ -284,26 +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` -const PLAN_FAILURE_REASONS: [&str; 6] = [ +/// `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", + "fail_closed_image_only", + "fail_closed_malformed", + "fail_closed_unsupported_op", + "stash_ambiguous", + "incomplete_toast", + "missing_stash_resolution", "detoast", "partial_update", "view", "drain", ]; -/// `reason=` label order of `MetricsSnapshot::descriptor_ambiguous_by_reason`, -/// matching [`crate::catalog::desc_log::AmbiguityReason`] variant order -const AMBIGUITY_REASONS: [&str; 5] = [ - "unknown_affected_relation", - "unknown_mutation_position", - "multiple_incompatible_layouts", - "never_visible_generation", - "incomplete_invalidation", -]; - pub fn render(snap: &MetricsSnapshot) -> String { use std::fmt::Write as _; let mut s = String::with_capacity(1024); @@ -945,19 +936,6 @@ pub fn render(snap: &MetricsSnapshot) -> String { 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(); - let name = "walshadow_descriptor_ambiguous_total"; - writeln!( - s, - "# HELP {name} Descriptor lookups landing in an ambiguity interval." - ) - .unwrap(); - writeln!(s, "# TYPE {name} counter").unwrap(); - for (reason, v) in AMBIGUITY_REASONS - .iter() - .zip(snap.descriptor_ambiguous_by_reason) - { - writeln!(s, "{name}{{reason=\"{reason}\"}} {v}").unwrap(); - } } // Umbrella count bare + per-mode labelled series in one family @@ -1121,7 +1099,7 @@ mod tests { 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], + xact_plan_failures_by_reason: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], route_snapshots_by_result: [7, 8], ..MetricsSnapshot::default() }; @@ -1130,16 +1108,40 @@ mod tests { 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")); - assert!(body.contains("walshadow_xact_plan_failures_total{reason=\"spool\"} 1")); - assert!(body.contains("walshadow_xact_plan_failures_total{reason=\"fail_closed\"} 2")); - assert!(body.contains("walshadow_xact_plan_failures_total{reason=\"detoast\"} 3")); - assert!(body.contains("walshadow_xact_plan_failures_total{reason=\"partial_update\"} 4")); - assert!(body.contains("walshadow_xact_plan_failures_total{reason=\"view\"} 5")); - assert!(body.contains("walshadow_xact_plan_failures_total{reason=\"drain\"} 6")); + 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 { @@ -1147,7 +1149,8 @@ mod tests { raw_decode_rows_by_op: [1, 2, 3, 4, 5, 6, 7], raw_pending_rows: 13, raw_pending_bytes: 14, - descriptor_ambiguous_by_reason: [21, 22, 23, 24, 25], + descriptor_ambiguous_total: 21, + desc_lookups_ambiguous_total: 22, ..MetricsSnapshot::default() }; snap.raw_stash_records_by_kind_op[0][0] = 31; // dirty insert @@ -1174,12 +1177,10 @@ mod tests { 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")); - assert!(body.contains( - "walshadow_descriptor_ambiguous_total{reason=\"unknown_affected_relation\"} 21" - )); - assert!(body.contains( - "walshadow_descriptor_ambiguous_total{reason=\"incomplete_invalidation\"} 25" - )); + // 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")] diff --git a/src/source/catalog_capture.rs b/src/source/catalog_capture.rs index 48d12d8d..9c3deaba 100644 --- a/src/source/catalog_capture.rs +++ b/src/source/catalog_capture.rs @@ -20,13 +20,17 @@ //! 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 only when the final descriptor provably reads the whole -//! dirty interval (`catalog::compat`). An 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. 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. +//! 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; @@ -34,6 +38,7 @@ 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, @@ -311,21 +316,32 @@ impl CatalogCapture { // a fresh generation has no covered predecessor let mut valid_from = first_touch; if !rotated && let Some(pred) = pred_desc.as_deref() { - let (from, ambiguity) = - in_place_verdict(pred, desc, first_touch, next_lsn); + let (from, published, incompat) = + in_place_verdict(pred, desc, oid, first_touch, next_lsn); valid_from = from; - if let Some((amb, why)) = ambiguity { + 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!("{:#X}", amb.from_lsn), - through = format_args!("{:#X}", amb.through_lsn), - why, + 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", ); - ambiguities.push(Arc::new(amb)); + // 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()); @@ -391,33 +407,45 @@ impl CatalogCapture { } } -/// Entry LSN for an in-place final version + the ambiguity covering the +/// 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 +/// 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, Option<(Ambiguity, &'static str)>) { - match crate::catalog::compat::compatible_reader(pred, fin) { - Ok(()) => (first_touch, None), - Err(why) => ( - next_lsn, - Some(( - Ambiguity { - scope: AmbiguityScope::Rfn(fin.rfn), - from_lsn: first_touch, - through_lsn: next_lsn, - reason: AmbiguityReason::UnknownMutationPosition, - }, - why, - )), - ), +) -> (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 @@ -479,22 +507,40 @@ mod tests { fn compatible_in_place_keeps_bias_early() { let pred = rel(23, 4, "t"); let fin = rel(23, 4, "renamed"); - let (from, amb) = in_place_verdict(&pred, &fin, 100, 500); + let (from, published, incompat) = in_place_verdict(&pred, &fin, 42, 100, 500); assert_eq!(from, 100); - assert!(amb.is_none()); + assert!(published.is_empty()); + assert_eq!(incompat, None); } #[test] - fn incompatible_in_place_publishes_interval() { + 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, amb) = in_place_verdict(&pred, &fin, 100, 500); + let (from, published, incompat) = in_place_verdict(&pred, &fin, 42, 100, 500); assert_eq!(from, 500, "final version serves post-commit rows only"); - let (amb, why) = amb.expect("ambiguity for type change"); - assert_eq!(amb.scope, AmbiguityScope::Rfn(fin.rfn)); - assert_eq!(amb.from_lsn, 100); - assert_eq!(amb.through_lsn, 500); - assert_eq!(amb.reason, AmbiguityReason::UnknownMutationPosition); - assert!(!why.is_empty()); + 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/xact/spill.rs b/src/xact/spill.rs index 1e5ba529..36aeccd6 100644 --- a/src/xact/spill.rs +++ b/src/xact/spill.rs @@ -197,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 { @@ -1401,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(); diff --git a/src/xact/xact_buffer.rs b/src/xact/xact_buffer.rs index ba1b0b57..c3650ca4 100644 --- a/src/xact/xact_buffer.rs +++ b/src/xact/xact_buffer.rs @@ -36,7 +36,7 @@ //! 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::collections::{BinaryHeap, HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -46,7 +46,7 @@ use tokio::sync::Mutex; use tracing::Instrument; use walrus::pg::walparser::{RelFileNode, RmId}; -use crate::catalog::desc_log::{DescriptorLog, LookupResult}; +use crate::catalog::desc_log::{Ambiguity, DescriptorLog, LookupResult}; use crate::decode::decoder_sink::{DecoderSinkError, DecoderStats}; use crate::decode::heap_decoder::{ ColumnValue, DecodedHeap, DescribedHeap, HeapOp, ToastPointer, decode_heap_record, @@ -226,22 +226,38 @@ pub enum XactBufferError { 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 - #[error("ordinary raw decode for rel {relid} at {lsn:#X} failed closed: {reason}")] + /// 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 filenode resolved inside a recorded ambiguity interval: no + /// 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} ambiguous at {lsn:#X}: {reason:?}")] + #[error( + "stash for filenode {rel_node} at {lsn:#X} inside ambiguity \ + [{from_lsn:#X}, {through_lsn:#X})" + )] StashAmbiguous { rel_node: u32, lsn: u64, - reason: crate::catalog::desc_log::AmbiguityReason, + 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 @@ -249,6 +265,12 @@ pub enum XactBufferError { 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 { @@ -395,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- @@ -418,13 +440,31 @@ 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 { @@ -513,7 +553,14 @@ pub enum StashOutcome { /// 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(Arc, u64), + 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` @@ -530,6 +577,11 @@ pub struct StashResolution { /// 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>, log: &DescriptorLog, @@ -550,26 +602,64 @@ pub async fn resolve_stash( } let mut outcomes: HashMap = HashMap::new(); let mut barriers: Vec<(u32, u64)> = Vec::new(); - for rfn in &rfns { - match log.descriptor_at_spanned(*rfn, next_lsn) { + for (rfn, mark) in &rfns { + let (rfn, mark) = (*rfn, *mark); + match log.descriptor_at_spanned(rfn, next_lsn) { Ok((rel, _)) if rel.kind == 't' => { - let marker = buffer.lock().await.marker_lsn(*rfn); + // 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)) => { - outcomes.insert(*rfn, StashOutcome::Ordinary(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, + }, + ); } - // No descriptor proven safe for the stashed rows; dropping them - // would be silent row loss, decoding them a corruption risk + // 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, - reason: a.reason, + from_lsn: a.from_lsn, + through_lsn: a.through_lsn, }); } // Dropped / rotated away by this xid or a later covered commit; @@ -592,7 +682,8 @@ pub async fn resolve_stash( for (toast_relid, marker_lsn) in barriers { 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 { @@ -887,7 +978,7 @@ impl XactBuffer { } } if xid != 0 { - self.state_for(xid, lsn).stash_rfns.insert(rfn); + self.mark_stash(xid, lsn, rfn, false); } } @@ -907,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 } @@ -915,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` @@ -925,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 } @@ -1179,7 +1290,15 @@ 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( @@ -1256,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); @@ -1667,10 +1790,19 @@ impl MergedDrain { }; let rel = match self.stash.outcomes.get(&rfn) { Some(StashOutcome::Toast(rel)) => rel.clone(), - Some(StashOutcome::Ordinary(rel, valid_from)) => { - let (rel, valid_from) = (rel.clone(), *valid_from); - return self.fold_raw_ordinary(raw, rel, valid_from); + 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(()); @@ -1700,12 +1832,14 @@ impl MergedDrain { /// 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 + /// 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, @@ -1715,8 +1849,21 @@ impl MergedDrain { 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 @@ -2479,7 +2626,12 @@ impl BufferingDecoderSink { let source_lsn = record.source_lsn; for relid in parsed.relids { // Same-xact CREATE + TRUNCATE: the rel's Added has no batch yet - // (capture runs at commit) → NotCovered, nothing lives to wipe + // (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 @@ -2566,11 +2718,12 @@ impl RecordSink for BufferingDecoderSink { // retained — a Present predecessor descriptor doesn't prove // decodability inside the dirty interval if record.defer_catalog_decode && txn_xid != 0 { - let raw = crate::xact::spill::RawRecord::from_parsed( + 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)?; @@ -2622,12 +2775,28 @@ impl RecordSink for BufferingDecoderSink { } // Filenode invisible at record LSN: created by this // still-open xact (same-xact CREATE / TRUNCATE / rewrite - // generation) or already superseded — resolve at commit. - // Ambiguous defers the same way: commit-time resolution - // decides under the post-boundary descriptor state - Err( - LookupResult::NotCovered | LookupResult::Dropped | LookupResult::Ambiguous(_), - ) => { + // 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()); + } return self.stash_invisible(record, rfn).await; } // Rotated away: every record on this rfn precedes the @@ -3022,12 +3191,47 @@ pub(crate) mod raw_fixtures { 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, 0x50)); + 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)] @@ -4108,6 +4312,186 @@ mod tests { ); } + /// 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")] diff --git a/tests/common/inproc_harness.rs b/tests/common/inproc_harness.rs index d498a8d2..92dbd9f4 100644 --- a/tests/common/inproc_harness.rs +++ b/tests/common/inproc_harness.rs @@ -879,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(); @@ -898,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 @@ -927,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) { diff --git a/tests/desc_log_e2e.rs b/tests/desc_log_e2e.rs index 423d9abf..b4a30289 100644 --- a/tests/desc_log_e2e.rs +++ b/tests/desc_log_e2e.rs @@ -289,7 +289,8 @@ async fn in_place_intervals_compatible_and_ambiguous() { &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_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, @@ -323,6 +324,7 @@ async fn in_place_intervals_compatible_and_ambiguous() { }; 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"); @@ -353,6 +355,7 @@ async fn in_place_intervals_compatible_and_ambiguous() { 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; @@ -369,30 +372,59 @@ async fn in_place_intervals_compatible_and_ambiguous() { let _ = shadow.stop(); let _ = source.stop(); - // Unproven widen: one rfn-scoped interval, Ambiguous inside, final - // version at through_lsn (= boundary next_lsn), predecessor before - let ambs = log.rfn_ambiguities(rfn_wid); - assert_eq!(ambs.len(), 1, "widen publishes one ambiguity: {ambs:?}"); + // 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); - assert!(matches!( - log.descriptor_at(rfn_wid, amb.from_lsn), - LookupResult::Ambiguous(_) - )); - assert!(matches!( - log.descriptor_at(rfn_wid, amb.through_lsn - 1), - LookupResult::Ambiguous(_) - )); - match log.descriptor_at(rfn_wid, amb.through_lsn) { - // varchar(20) typmod = 24 - LookupResult::Present(d) => assert_eq!(d.attributes[1].typmod, 24), + 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_wid, amb.from_lsn) + .present_before(rfn_typ, amb.from_lsn) .expect("durable predecessor"); - assert_eq!(pred.attributes[1].typmod, 14, "varchar(10) 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 diff --git a/tests/dirty_admission_e2e.rs b/tests/dirty_admission_e2e.rs index 1c12f85a..f17eed22 100644 --- a/tests/dirty_admission_e2e.rs +++ b/tests/dirty_admission_e2e.rs @@ -55,6 +55,20 @@ const SLOT_CREATE_COPY: PortSlot = PortSlot { 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, @@ -447,6 +461,114 @@ async fn create_table_and_copy_same_xact_delivers() { ); } +/// 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.