Skip to main content

faucet_source_postgres_cdc/
stream.rs

1//! `PostgresCdcSource` — public `Source` implementation.
2
3use crate::config::PostgresCdcSourceConfig;
4use crate::pgoutput::decoder::decode_message;
5use crate::pgoutput::messages::{
6    Delete, Insert, Message, Relation, Truncate, TupleCell, TupleData, Update,
7};
8use crate::pgoutput::registry::RelationRegistry;
9use crate::pgoutput::values::text_to_json;
10use crate::replication::{
11    self, ReplicationEvent, ReplicationParams, postgres_clock_to_unix_ms, recv, send_status_update,
12};
13use crate::state::{Bookmark, format_lsn, parse_lsn, state_key};
14use async_trait::async_trait;
15use faucet_core::{FaucetError, Source, Stream, StreamPage};
16use serde_json::{Map, Value, json};
17use std::collections::HashMap;
18use std::pin::Pin;
19use std::time::{Duration, Instant};
20use tokio::sync::Mutex;
21
22pub struct PostgresCdcSource {
23    config: PostgresCdcSourceConfig,
24    state_key_value: String,
25    /// Bookmark provided by `apply_start_bookmark`, applied at the start of
26    /// the next fetch cycle. Becomes the new "confirmed_flush_lsn" we
27    /// advertise to Postgres.
28    pending_bookmark: Mutex<Option<Bookmark>>,
29    /// Last LSN we have told Postgres is durable (advertised as the slot's
30    /// `confirmed_flush_lsn`, which authorises WAL recycling up to it).
31    ///
32    /// Advanced **only** by `apply_start_bookmark` — i.e. after the pipeline
33    /// has durably persisted a bookmark — or seeded from `start_lsn` config.
34    /// It is never advanced from decoded WAL (commit-decode time) or from
35    /// keepalive `wal_end`, because those positions are not yet durable
36    /// downstream; doing so would let Postgres discard WAL for unwritten
37    /// changes and lose data on a crash (#78/#1).
38    confirmed_lsn: Mutex<u64>,
39}
40
41impl PostgresCdcSource {
42    pub async fn new(config: PostgresCdcSourceConfig) -> Result<Self, FaucetError> {
43        config.validate()?;
44        let key = state_key(&config.slot_name);
45        let initial_lsn = match config.start_lsn.as_deref() {
46            Some(s) => parse_lsn(s)?,
47            None => 0,
48        };
49        Ok(Self {
50            config,
51            state_key_value: key,
52            pending_bookmark: Mutex::new(None),
53            confirmed_lsn: Mutex::new(initial_lsn),
54        })
55    }
56
57    /// Drop this source's replication slot on the server, freeing the WAL it
58    /// pins. A no-op if the slot doesn't exist; errors if the slot is still
59    /// active (in use by a live replication connection). Call this when
60    /// decommissioning a `permanent` slot so it doesn't leak WAL (#78/#12).
61    pub async fn drop_slot(&self) -> Result<(), FaucetError> {
62        replication::drop_slot(&self.config.connection_url, &self.config.slot_name).await
63    }
64}
65
66#[async_trait]
67impl Source for PostgresCdcSource {
68    async fn fetch_with_context(
69        &self,
70        ctx: &HashMap<String, Value>,
71    ) -> Result<Vec<Value>, FaucetError> {
72        let (records, _bookmark) = self.fetch_with_context_incremental(ctx).await?;
73        Ok(records)
74    }
75
76    /// Drain the replication stream into a single `Vec<Value>` plus the
77    /// bookmark of the most recent COMMIT.
78    ///
79    /// Implemented by collecting [`Source::stream_pages`] with the
80    /// `batch_size = 0` sentinel — that sentinel coalesces every transaction
81    /// in the run window into a single trailing page, which exactly matches
82    /// the historical `fetch_with_context_incremental` contract (one
83    /// aggregated buffer, one max-LSN bookmark). The streaming pipeline
84    /// (`Pipeline::run` / `run_stream`) drives `stream_pages` directly with
85    /// the per-source `batch_size` config field instead so it gets
86    /// per-transaction durability.
87    async fn fetch_with_context_incremental(
88        &self,
89        ctx: &HashMap<String, Value>,
90    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
91        use futures::StreamExt;
92        let mut pages = self.stream_pages_with_batch_size(ctx, 0);
93        let mut all: Vec<Value> = Vec::new();
94        let mut bookmark: Option<Value> = None;
95        while let Some(page) = pages.next().await {
96            let page = page?;
97            all.extend(page.records);
98            if page.bookmark.is_some() {
99                bookmark = page.bookmark;
100            }
101        }
102        Ok((all, bookmark))
103    }
104
105    /// Per-transaction streaming.
106    ///
107    /// Each committed transaction is emitted as its own
108    /// [`StreamPage`] with `bookmark = Some(commit_lsn)`. Because the
109    /// pipeline flushes the sink and persists the bookmark on every page
110    /// that carries one, a mid-stream crash recovers from the last fully-
111    /// committed transaction with no partial-transaction leakage.
112    ///
113    /// **Atomic transactions.** A transaction is never split across pages.
114    /// If a single transaction's record count exceeds
115    /// [`PostgresCdcSourceConfig::batch_size`] it is still emitted as one
116    /// page; `batch_size` is advisory.
117    ///
118    /// **`batch_size = 0`** is the "no batching" sentinel: every committed
119    /// transaction during the run window is accumulated into a single
120    /// trailing page with `bookmark = max(commit_lsn)`. This negates
121    /// per-transaction durability and is only useful for tests / initial
122    /// snapshot runs.
123    ///
124    /// The trait-level `batch_size` argument is intentionally ignored in
125    /// favour of the config field (matches the convention used by the
126    /// query-mode postgres source and the rest source).
127    fn stream_pages<'a>(
128        &'a self,
129        ctx: &'a HashMap<String, Value>,
130        _batch_size: usize,
131    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
132        self.stream_pages_with_batch_size(ctx, self.config.batch_size)
133    }
134
135    fn config_schema(&self) -> Value {
136        let schema = schemars::schema_for!(PostgresCdcSourceConfig);
137        serde_json::to_value(&schema).unwrap_or(Value::Null)
138    }
139
140    fn state_key(&self) -> Option<String> {
141        Some(self.state_key_value.clone())
142    }
143
144    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
145        let parsed = Bookmark::from_value(bookmark)?;
146        // Update confirmed_lsn so the next initial status update (in the next
147        // fetch cycle) advertises the correct position.
148        *self.confirmed_lsn.lock().await = parsed.as_u64()?;
149        *self.pending_bookmark.lock().await = Some(parsed);
150        Ok(())
151    }
152
153    fn supports_exactly_once(&self) -> bool {
154        // Durable monotonic LSN + deterministic replay from it + per-transaction
155        // (per-page) bookmarks persisted only after the sink confirms — the
156        // requirements for exactly-once delivery.
157        true
158    }
159
160    fn connector_name(&self) -> &'static str {
161        "postgres-cdc"
162    }
163
164    fn dataset_uri(&self) -> String {
165        format!(
166            "{}?publication={}",
167            faucet_core::redact_uri_credentials(&self.config.connection_url),
168            self.config.publication_name
169        )
170    }
171
172    /// Preflight probe that does **not** start replication.
173    ///
174    /// The default [`Source::check`] would call `stream_pages`, which opens the
175    /// replication stream and consumes/holds WAL (a side effect that pins server
176    /// resources) — unacceptable as a preflight. Instead we open a *normal*
177    /// (non-replication) SQL connection — the same connection style
178    /// `ensure_slot` uses — and inspect the slot catalog without touching the
179    /// replication protocol:
180    ///
181    /// - connection fails → `auth` probe `Fail` (could not connect / authenticate),
182    /// - connected but the slot row is absent → `slot` probe `Skip`
183    ///   (faucet can create it on the first run),
184    /// - slot present → `slot` probe `Pass`.
185    ///
186    /// The whole call is bounded by `ctx.timeout`.
187    async fn check(
188        &self,
189        ctx: &faucet_core::check::CheckContext,
190    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
191        use faucet_core::check::{CheckReport, Probe};
192        use sqlx::ConnectOptions as _;
193        use sqlx::postgres::{PgConnectOptions, PgConnection};
194
195        let start = std::time::Instant::now();
196
197        // A bad connection URL is a config error, not an unreachable server.
198        let opts: PgConnectOptions = match self.config.connection_url.parse() {
199            Ok(o) => o,
200            Err(e) => {
201                return Ok(CheckReport::single(Probe::fail_hint(
202                    "auth",
203                    start.elapsed(),
204                    format!("invalid connection URL: {e}"),
205                    "connection_url must be a valid postgres:// URL",
206                )));
207            }
208        };
209
210        // Bound the whole connect+query under ctx.timeout so an unreachable
211        // host doesn't hang the probe.
212        let probe = async {
213            let mut conn: PgConnection = opts.connect().await.map_err(|e| {
214                Probe::fail_hint(
215                    "auth",
216                    start.elapsed(),
217                    format!("could not connect: {e}"),
218                    "verify the host is reachable and credentials are valid",
219                )
220            })?;
221
222            let row: Option<(String,)> = sqlx::query_as(
223                "SELECT slot_name::text FROM pg_replication_slots WHERE slot_name = $1",
224            )
225            .bind(&self.config.slot_name)
226            .fetch_optional(&mut conn)
227            .await
228            .map_err(|e| {
229                Probe::fail(
230                    "slot",
231                    start.elapsed(),
232                    format!("could not query pg_replication_slots: {e}"),
233                )
234            })?;
235
236            Ok::<Probe, Probe>(match row {
237                Some(_) => Probe::pass("slot", start.elapsed()),
238                None => Probe::skip(
239                    "slot",
240                    format!(
241                        "replication slot {} does not exist yet (faucet run can create it)",
242                        self.config.slot_name
243                    ),
244                ),
245            })
246        };
247
248        let probe = match tokio::time::timeout(ctx.timeout, probe).await {
249            Ok(Ok(p)) | Ok(Err(p)) => p,
250            Err(_elapsed) => Probe::fail_hint(
251                "auth",
252                start.elapsed(),
253                "connection timed out",
254                "the database did not respond within the check timeout",
255            ),
256        };
257        Ok(CheckReport::single(probe))
258    }
259}
260
261impl PostgresCdcSource {
262    /// Shared streaming implementation used by both `stream_pages` (per-
263    /// transaction page emission when `batch_size > 0`) and the legacy
264    /// `fetch_with_context_incremental` (single-page aggregation when
265    /// `batch_size == 0`).
266    ///
267    /// The slot lifecycle (`connect` → `ensure_slot` → `start_replication`)
268    /// and Standby Status Update bootstrap are identical to the pre-Plan-15
269    /// behaviour. The only difference is when records cross the page
270    /// boundary: on every COMMIT (`batch_size > 0`) or once at the end of
271    /// the run window (`batch_size == 0`).
272    fn stream_pages_with_batch_size<'a>(
273        &'a self,
274        _ctx: &'a HashMap<String, Value>,
275        batch_size: usize,
276    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
277        let max_messages = self.config.max_messages.unwrap_or(usize::MAX);
278        let idle_timeout = self.config.idle_timeout;
279        let per_transaction = batch_size != 0;
280
281        Box::pin(async_stream::try_stream! {
282            // 1. Resolve start_lsn for THIS fetch cycle.
283            let pending = {
284                let mut g = self.pending_bookmark.lock().await;
285                g.take()
286            };
287            let start_lsn = if let Some(b) = pending.as_ref() {
288                let lsn = b.as_u64()?;
289                *self.confirmed_lsn.lock().await = lsn;
290                Some(lsn)
291            } else {
292                self.config
293                    .start_lsn
294                    .as_deref()
295                    .map(parse_lsn)
296                    .transpose()?
297            };
298
299            // 2. Open replication connection + ensure slot + START_REPLICATION.
300            let params = ReplicationParams {
301                connection_url: &self.config.connection_url,
302                slot_name: &self.config.slot_name,
303                publication_name: &self.config.publication_name,
304                proto_version: self.config.proto_version,
305                create_slot_if_missing: self.config.create_slot_if_missing,
306                start_lsn,
307                status_update_interval: self.config.status_update_interval,
308                tcp_keepalive: self.config.tcp_keepalive,
309                slot_type: self.config.slot_type,
310                tls: &self.config.tls,
311            };
312            let client = replication::connect(&params).await?;
313            replication::ensure_slot(
314                &client,
315                &self.config.connection_url,
316                &self.config.slot_name,
317                self.config.create_slot_if_missing,
318                self.config.slot_type,
319            )
320            .await?;
321
322            // Advance the slot's confirmed_flush_lsn to the durable resume
323            // point BEFORE opening the stream. For a logical slot,
324            // START_REPLICATION resumes decoding from confirmed_flush_lsn (the
325            // client-supplied start LSN does not skip already-committed
326            // transactions), so this is the only way to skip changes we have
327            // already consumed and durably persisted. `start_lsn` is set only
328            // from a durable bookmark (apply_start_bookmark) or the start_lsn
329            // config override; when it is `None` (no durable resume point) we
330            // do NOT advance, so an interrupted run with no persisted bookmark
331            // redelivers rather than loses data (#78/#1).
332            if let Some(lsn) = start_lsn {
333                // Both the slot advance and START_REPLICATION require the slot
334                // to be inactive; on a rapid re-run the prior connection may not
335                // have released it yet ("slot is active"). Retry with bounded
336                // backoff instead of failing the whole run (#146 M12).
337                replication::retry_on_slot_active(self.config.slot_acquire_retries, || {
338                    replication::advance_slot(
339                        &self.config.connection_url,
340                        &self.config.slot_name,
341                        lsn,
342                    )
343                })
344                .await?;
345            }
346
347            let mut duplex = replication::retry_on_slot_active(
348                self.config.slot_acquire_retries,
349                || replication::start_replication(&client, &params),
350            )
351            .await?;
352
353            // Send an initial Standby Status Update advertising the same
354            // durable position so the server's bookkeeping is consistent.
355            let initial_confirmed = *self.confirmed_lsn.lock().await;
356            send_status_update(&mut duplex, initial_confirmed, false).await?;
357
358            // 4. Drain the replication stream until idle_timeout, ctrl_c, or
359            //    max_messages. Per-transaction mode (`batch_size > 0`) emits
360            //    one page per COMMIT carrying `bookmark = Some(commit_lsn)`.
361            //    Aggregated mode (`batch_size == 0`) accumulates every
362            //    transaction's records into one buffer and emits a single
363            //    trailing page with `bookmark = max(commit_lsn)`.
364            let mut registry = RelationRegistry::new();
365            let mut state = TxnState {
366                max_staged_records: self.config.max_staged_records,
367                ..TxnState::default()
368            };
369            let mut agg_records: Vec<Value> = Vec::new();
370            let mut total_records: usize = 0;
371            let mut last_message_at = Instant::now();
372
373            loop {
374                let idle_deadline = last_message_at + idle_timeout;
375                let budget = idle_deadline
376                    .checked_duration_since(Instant::now())
377                    .unwrap_or(Duration::ZERO);
378
379                // Tracks per-iteration outcomes that we cannot propagate
380                // directly from inside `tokio::select!` arms while remaining
381                // inside `try_stream!`.
382                let mut stop = false;
383                let mut just_committed: Option<(u64, Vec<Value>)> = None;
384                let mut fatal: Option<FaucetError> = None;
385                let mut unexpected_end = false;
386                tokio::select! {
387                    biased;
388                    _ = tokio::signal::ctrl_c() => {
389                        tracing::info!("postgres-cdc: ctrl_c received, stopping cleanly");
390                        stop = true;
391                    }
392                    ev = tokio::time::timeout(budget, recv(&mut duplex)) => {
393                        match ev {
394                            Ok(Ok(Some(event))) => {
395                                // Reset on any server activity, not just committed
396                                // output, so idle_timeout never fires mid-transaction
397                                // while WAL is still flowing.
398                                last_message_at = Instant::now();
399                                let was_in_txn = state.in_txn;
400                                let pre_commit_count = state.last_committed;
401                                let mut committed_records: Vec<Value> = Vec::new();
402                                if let Err(e) = handle_event(
403                                    event,
404                                    &mut registry,
405                                    &mut state,
406                                    &mut committed_records,
407                                ) {
408                                    fatal = Some(e);
409                                } else if was_in_txn
410                                    && !state.in_txn
411                                    && state.last_committed != pre_commit_count
412                                {
413                                    // A COMMIT was just processed and
414                                    // `committed_records` holds the drained
415                                    // staged records.
416                                    let lsn = state.last_committed
417                                        .expect("last_committed set on commit");
418                                    total_records += committed_records.len();
419                                    just_committed = Some((lsn, committed_records));
420                                }
421                            }
422                            Ok(Ok(None)) => {
423                                unexpected_end = true;
424                            }
425                            Ok(Err(e)) => {
426                                fatal = Some(e);
427                            }
428                            Err(_timeout) => {
429                                tracing::debug!(
430                                    "postgres-cdc: idle_timeout reached, stopping"
431                                );
432                                stop = true;
433                            }
434                        }
435                    }
436                }
437
438                if let Some(e) = fatal {
439                    Err(e)?;
440                }
441                if unexpected_end {
442                    Err(FaucetError::Source(
443                        "postgres-cdc: replication stream ended unexpectedly".into(),
444                    ))?;
445                }
446                if let Some((lsn, drained)) = just_committed {
447                    // NOTE: we deliberately do NOT advance `confirmed_lsn` here.
448                    // `confirmed_lsn` is the position advertised to Postgres as
449                    // `confirmed_flush_lsn` (which lets the server recycle WAL),
450                    // and it must only ever reflect data the consumer has
451                    // *durably* persisted. The only durable signal is the
452                    // bookmark the pipeline persists after the sink flush, which
453                    // arrives back via `apply_start_bookmark` at the start of the
454                    // next run. Advancing here at commit-decode time would tell
455                    // Postgres to discard WAL for changes that were never written
456                    // downstream — a crash in that window loses data (#78/#1).
457                    if per_transaction {
458                        let bookmark = Some(Bookmark::from_u64(lsn).to_value()?);
459                        yield StreamPage {
460                            records: drained,
461                            bookmark,
462                        };
463                    } else {
464                        agg_records.extend(drained);
465                    }
466                    if total_records >= max_messages {
467                        stop = true;
468                    }
469                }
470
471                if stop {
472                    break;
473                }
474            }
475
476            // 5. In aggregated mode emit the single trailing page (carrying
477            //    the max LSN seen). In per-transaction mode the trailing
478            //    `state.staged` is an *uncommitted* partial transaction and
479            //    must be dropped — Postgres will redeliver after the next
480            //    START_REPLICATION.
481            if !per_transaction
482                && let Some(lsn) = state.last_committed
483            {
484                let bookmark = Some(Bookmark::from_u64(lsn).to_value()?);
485                yield StreamPage {
486                    records: agg_records,
487                    bookmark,
488                };
489            }
490
491            tracing::info!(
492                records = total_records,
493                batch_size,
494                "postgres-cdc: stream complete",
495            );
496        })
497    }
498}
499
500/// One decoded tuple, split into its values and the names of any unchanged
501/// (large/TOAST) columns whose value the server didn't re-send.
502struct TupleRow {
503    values: Map<String, Value>,
504    unchanged_toast: Vec<String>,
505}
506
507/// In-flight transaction state while draining the replication stream.
508#[derive(Default)]
509struct TxnState {
510    /// Records produced inside the current BEGIN..COMMIT, buffered until
511    /// COMMIT is seen so partial transactions never leak into the output.
512    /// On COMMIT, `handle_event` drains this into the caller-supplied
513    /// `out: &mut Vec<Value>`.
514    staged: Vec<Value>,
515    /// commit_lsn of the most recently fully-applied transaction.
516    last_committed: Option<u64>,
517    /// commit_ts (Postgres epoch micros) of the in-progress transaction,
518    /// set by BEGIN.
519    in_progress_ts: i64,
520    /// commit_lsn announced by the in-progress BEGIN (== final_lsn).
521    in_progress_lsn: u64,
522    /// Whether we are currently inside a BEGIN..COMMIT pair.
523    in_txn: bool,
524    /// Optional cap on `staged.len()` for a single transaction. `None` means
525    /// unbounded. Exceeding it aborts the run with a typed error instead of
526    /// risking an OOM-kill on a huge bulk transaction (see
527    /// [`PostgresCdcSourceConfig::max_staged_records`]).
528    max_staged_records: Option<usize>,
529}
530
531impl TxnState {
532    /// Stage one decoded change record, enforcing
533    /// [`max_staged_records`](Self::max_staged_records).
534    fn push_staged(&mut self, record: Value) -> Result<(), FaucetError> {
535        if let Some(max) = self.max_staged_records
536            && self.staged.len() >= max
537        {
538            return Err(FaucetError::Source(format!(
539                "postgres-cdc: in-progress transaction exceeded max_staged_records ({max}); \
540                 aborting to avoid unbounded memory growth. Raise max_staged_records or \
541                 reduce the size of the source transaction."
542            )));
543        }
544        self.staged.push(record);
545        Ok(())
546    }
547}
548
549fn handle_event(
550    event: ReplicationEvent,
551    registry: &mut RelationRegistry,
552    state: &mut TxnState,
553    out: &mut Vec<Value>,
554) -> Result<(), FaucetError> {
555    match event {
556        ReplicationEvent::Begin {
557            final_lsn,
558            commit_time_micros,
559            xid: _,
560        } => {
561            if state.in_txn {
562                // A second BEGIN without an intervening COMMIT is a protocol
563                // desync: silently discarding the staged records would drop
564                // committed-but-unemitted changes. Fail fast so the run
565                // restarts cleanly from the durable bookmark (#78/#46).
566                return Err(FaucetError::Source(format!(
567                    "postgres-cdc: BEGIN received while a previous transaction was still \
568                     in progress ({} records staged) — replication stream desync",
569                    state.staged.len()
570                )));
571            }
572            state.in_txn = true;
573            state.in_progress_lsn = final_lsn.as_u64();
574            state.in_progress_ts = commit_time_micros;
575            state.staged.clear();
576        }
577        ReplicationEvent::Commit {
578            lsn: _,
579            commit_time_micros: _,
580            end_lsn,
581        } => {
582            if !state.in_txn {
583                return Err(FaucetError::Source(
584                    "postgres-cdc: COMMIT without BEGIN".into(),
585                ));
586            }
587            // Drain staged records into the caller-supplied output buffer.
588            // The caller is responsible for emitting a StreamPage with these
589            // records and the bookmark.
590            //
591            // The bookmark is the commit's `end_lsn` — the WAL position
592            // immediately AFTER the commit record — not the commit_lsn. To
593            // resume *past* a consumed transaction the slot's
594            // confirmed_flush_lsn must be set to a position beyond the commit
595            // record; advancing only to commit_lsn leaves the commit record
596            // unconfirmed and Postgres redelivers the whole transaction
597            // (#78/#1). `end_lsn` is the position a standby reports as flushed.
598            //
599            // The exactly-once-across-resume boundary (a transaction whose
600            // commit lands exactly at the persisted bookmark is delivered once,
601            // not skipped or duplicated) is exercised by the Docker integration
602            // tests `resume_from_bookmark_skips_already_consumed` and
603            // `lsn_not_advanced_without_durable_bookmark_redelivers` (#78 LOW).
604            out.append(&mut state.staged);
605            state.last_committed = Some(end_lsn.as_u64());
606            state.in_txn = false;
607        }
608        ReplicationEvent::XLogData { data, .. } => {
609            let msg = decode_message(&data)?;
610            handle_pgoutput(msg, registry, state)?;
611        }
612        ReplicationEvent::Message { .. } => {
613            // pg_logical_emit_message — a user-emitted logical message, never a
614            // table change. Intentionally ignored by this row-oriented source.
615        }
616        // KeepAlive and StoppedAt are filtered inside recv(). Any other variant
617        // is one this decoder doesn't understand — it may carry table data, so
618        // dropping it silently risks data loss. Fail fast instead (#78/#46).
619        other => {
620            return Err(FaucetError::Source(format!(
621                "postgres-cdc: unhandled ReplicationEvent variant {other:?} — refusing to \
622                 continue rather than risk silently dropping change data"
623            )));
624        }
625    }
626    Ok(())
627}
628
629fn handle_pgoutput(
630    msg: Message,
631    registry: &mut RelationRegistry,
632    state: &mut TxnState,
633) -> Result<(), FaucetError> {
634    match msg {
635        Message::Relation(r) => registry.insert(r),
636        Message::Origin | Message::Type => {} // ignored
637        Message::Insert(i) => stage_insert(state, registry, i)?,
638        Message::Update(u) => stage_update(state, registry, u)?,
639        Message::Delete(d) => stage_delete(state, registry, d)?,
640        Message::Truncate(t) => stage_truncate(state, registry, t)?,
641        // Begin/Commit pgoutput messages should never arrive here — the
642        // pgwire-replication library decodes them into structured
643        // ReplicationEvent::Begin / Commit variants, handled in handle_event.
644        // If we see one, log a warning and ignore.
645        Message::Begin(_) | Message::Commit(_) => {
646            tracing::warn!(
647                "postgres-cdc: pgoutput Begin/Commit reached pgoutput decoder; \
648                 pgwire-replication should have intercepted it"
649            );
650        }
651    }
652    Ok(())
653}
654
655fn stage_insert(
656    state: &mut TxnState,
657    registry: &RelationRegistry,
658    i: Insert,
659) -> Result<(), FaucetError> {
660    let rel = registry.get(i.relation_oid)?;
661    let after = tuple_to_object(rel, &i.new)?;
662    let r = record(rel, "insert", state, None, Some(after));
663    state.push_staged(r)
664}
665
666fn stage_update(
667    state: &mut TxnState,
668    registry: &RelationRegistry,
669    u: Update,
670) -> Result<(), FaucetError> {
671    let rel = registry.get(u.relation_oid)?;
672    let before = match &u.old {
673        Some(t) => Some(tuple_to_object(rel, t)?),
674        None => None,
675    };
676    let after = tuple_to_object(rel, &u.new)?;
677    let r = record(rel, "update", state, before, Some(after));
678    state.push_staged(r)
679}
680
681fn stage_delete(
682    state: &mut TxnState,
683    registry: &RelationRegistry,
684    d: Delete,
685) -> Result<(), FaucetError> {
686    let rel = registry.get(d.relation_oid)?;
687    let before = Some(tuple_to_object(rel, &d.old)?);
688    let r = record(rel, "delete", state, before, None);
689    state.push_staged(r)
690}
691
692fn stage_truncate(
693    state: &mut TxnState,
694    registry: &RelationRegistry,
695    t: Truncate,
696) -> Result<(), FaucetError> {
697    for oid in &t.relation_oids {
698        let rel = registry.get(*oid)?;
699        let r = record(rel, "truncate", state, None, None);
700        state.push_staged(r)?;
701    }
702    Ok(())
703}
704
705fn record(
706    rel: &Relation,
707    op: &str,
708    state: &TxnState,
709    before: Option<TupleRow>,
710    after: Option<TupleRow>,
711) -> Value {
712    fn to_value(row: TupleRow) -> Value {
713        let mut o = row.values;
714        if !row.unchanged_toast.is_empty() {
715            o.insert("__unchanged_toast__".into(), json!(row.unchanged_toast));
716        }
717        Value::Object(o)
718    }
719    let mut obj = Map::new();
720    obj.insert("op".into(), json!(op));
721    obj.insert("schema".into(), json!(rel.namespace));
722    obj.insert("table".into(), json!(rel.name));
723    obj.insert("lsn".into(), json!(format_lsn(state.in_progress_lsn)));
724    obj.insert(
725        "ts_ms".into(),
726        json!(postgres_clock_to_unix_ms(state.in_progress_ts)),
727    );
728    obj.insert("before".into(), before.map(to_value).unwrap_or(Value::Null));
729    obj.insert("after".into(), after.map(to_value).unwrap_or(Value::Null));
730    Value::Object(obj)
731}
732
733/// Convert a tuple's text cells to a [`TupleRow`].
734fn tuple_to_object(rel: &Relation, tup: &TupleData) -> Result<TupleRow, FaucetError> {
735    if tup.cells.len() != rel.columns.len() {
736        return Err(FaucetError::Source(format!(
737            "postgres-cdc: tuple has {} cells but relation {}.{} has {} columns",
738            tup.cells.len(),
739            rel.namespace,
740            rel.name,
741            rel.columns.len()
742        )));
743    }
744    let mut values = Map::with_capacity(rel.columns.len());
745    let mut unchanged_toast = Vec::new();
746    for (col, cell) in rel.columns.iter().zip(&tup.cells) {
747        match cell {
748            TupleCell::Null => {
749                values.insert(col.name.clone(), Value::Null);
750            }
751            TupleCell::UnchangedToast => {
752                unchanged_toast.push(col.name.clone());
753            }
754            TupleCell::Text(s) => {
755                values.insert(col.name.clone(), text_to_json(col.type_oid, s)?);
756            }
757        }
758    }
759    Ok(TupleRow {
760        values,
761        unchanged_toast,
762    })
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768    use crate::pgoutput::messages::{ColumnDesc, ReplicaIdentity};
769    use crate::replication::ReplicationEvent;
770    use pgwire_replication::Lsn;
771
772    fn rel_users() -> Relation {
773        Relation {
774            oid: 16384,
775            namespace: "public".into(),
776            name: "users".into(),
777            replica_identity: ReplicaIdentity::Default,
778            columns: vec![
779                ColumnDesc {
780                    flags: 1,
781                    name: "id".into(),
782                    type_oid: 23,
783                    type_modifier: -1,
784                },
785                ColumnDesc {
786                    flags: 0,
787                    name: "name".into(),
788                    type_oid: 25,
789                    type_modifier: -1,
790                },
791            ],
792        }
793    }
794
795    fn xlogdata(payload: Vec<u8>) -> ReplicationEvent {
796        ReplicationEvent::XLogData {
797            wal_start: Lsn::from_u64(0),
798            wal_end: Lsn::from_u64(0x16A_4F88),
799            server_time_micros: 0,
800            data: bytes::Bytes::from(payload),
801        }
802    }
803
804    fn insert_payload(relation_oid: u32, cells: &[(&str, &str)]) -> Vec<u8> {
805        let mut buf: Vec<u8> = Vec::new();
806        buf.push(b'I');
807        buf.extend_from_slice(&relation_oid.to_be_bytes());
808        buf.push(b'N');
809        buf.extend_from_slice(&(cells.len() as u16).to_be_bytes());
810        for (_, val) in cells {
811            text_cell(&mut buf, val);
812        }
813        buf
814    }
815
816    /// 'U' relation 'O' fullold 'N' new — exercises REPLICA IDENTITY FULL.
817    fn update_full_payload(
818        relation_oid: u32,
819        old_cells: &[(&str, &str)],
820        new_cells: &[(&str, &str)],
821    ) -> Vec<u8> {
822        let mut buf: Vec<u8> = Vec::new();
823        buf.push(b'U');
824        buf.extend_from_slice(&relation_oid.to_be_bytes());
825        buf.push(b'O');
826        buf.extend_from_slice(&(old_cells.len() as u16).to_be_bytes());
827        for (_, val) in old_cells {
828            text_cell(&mut buf, val);
829        }
830        buf.push(b'N');
831        buf.extend_from_slice(&(new_cells.len() as u16).to_be_bytes());
832        for (_, val) in new_cells {
833            text_cell(&mut buf, val);
834        }
835        buf
836    }
837
838    /// 'D' relation 'O' fullold — REPLICA IDENTITY FULL delete.
839    fn delete_full_payload(relation_oid: u32, old_cells: &[(&str, &str)]) -> Vec<u8> {
840        let mut buf: Vec<u8> = Vec::new();
841        buf.push(b'D');
842        buf.extend_from_slice(&relation_oid.to_be_bytes());
843        buf.push(b'O');
844        buf.extend_from_slice(&(old_cells.len() as u16).to_be_bytes());
845        for (_, val) in old_cells {
846            text_cell(&mut buf, val);
847        }
848        buf
849    }
850
851    /// 'T' flags=0 oids... — truncate without cascade/restart_identity.
852    fn truncate_payload(relation_oids: &[u32]) -> Vec<u8> {
853        let mut buf: Vec<u8> = Vec::new();
854        buf.push(b'T');
855        buf.extend_from_slice(&(relation_oids.len() as u32).to_be_bytes());
856        buf.push(0u8); // flags
857        for oid in relation_oids {
858            buf.extend_from_slice(&oid.to_be_bytes());
859        }
860        buf
861    }
862
863    fn text_cell(buf: &mut Vec<u8>, val: &str) {
864        buf.push(b't');
865        buf.extend_from_slice(&(val.len() as u32).to_be_bytes());
866        buf.extend_from_slice(val.as_bytes());
867    }
868
869    fn begin_event(final_lsn: u64) -> ReplicationEvent {
870        ReplicationEvent::Begin {
871            final_lsn: Lsn::from_u64(final_lsn),
872            xid: 1,
873            commit_time_micros: 0,
874        }
875    }
876
877    fn commit_event(lsn: u64) -> ReplicationEvent {
878        ReplicationEvent::Commit {
879            lsn: Lsn::from_u64(lsn),
880            end_lsn: Lsn::from_u64(lsn + 0x10),
881            commit_time_micros: 0,
882        }
883    }
884
885    #[test]
886    fn full_transaction_promotes_to_output_on_commit() {
887        let mut registry = RelationRegistry::new();
888        registry.insert(rel_users());
889        let mut state = TxnState::default();
890        let mut out = vec![];
891
892        handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
893        assert!(out.is_empty());
894
895        handle_event(
896            xlogdata(insert_payload(16384, &[("id", "1"), ("name", "alice")])),
897            &mut registry,
898            &mut state,
899            &mut out,
900        )
901        .unwrap();
902        assert!(out.is_empty(), "records stay staged until COMMIT");
903
904        handle_event(
905            commit_event(0x16A_4F88),
906            &mut registry,
907            &mut state,
908            &mut out,
909        )
910        .unwrap();
911
912        assert_eq!(out.len(), 1);
913        assert_eq!(out[0]["op"], "insert");
914        assert_eq!(out[0]["schema"], "public");
915        assert_eq!(out[0]["table"], "users");
916        assert_eq!(out[0]["lsn"], "0/16A4F88");
917        assert_eq!(out[0]["after"]["id"], 1);
918        assert_eq!(out[0]["after"]["name"], "alice");
919        assert_eq!(out[0]["before"], Value::Null);
920
921        // The bookmark is the commit's `end_lsn` (the resume position just
922        // past the commit record), not the commit_lsn. `commit_event` sets
923        // end_lsn = commit_lsn + 0x10. The record's own "lsn" field above is
924        // still the commit_lsn (display/identity), which is intentional.
925        assert_eq!(state.last_committed, Some(0x16A_4F88 + 0x10));
926    }
927
928    #[test]
929    fn staging_beyond_max_staged_records_aborts() {
930        // Regression for #78/#2: a single transaction that stages more records
931        // than `max_staged_records` must abort with a typed error rather than
932        // buffering unboundedly (OOM risk).
933        let mut registry = RelationRegistry::new();
934        registry.insert(rel_users());
935        let mut state = TxnState {
936            max_staged_records: Some(2),
937            ..TxnState::default()
938        };
939        let mut out = vec![];
940
941        handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
942        // First two inserts stage fine.
943        for id in ["1", "2"] {
944            handle_event(
945                xlogdata(insert_payload(16384, &[("id", id), ("name", "x")])),
946                &mut registry,
947                &mut state,
948                &mut out,
949            )
950            .unwrap();
951        }
952        // The third exceeds the cap and must error.
953        let err = handle_event(
954            xlogdata(insert_payload(16384, &[("id", "3"), ("name", "x")])),
955            &mut registry,
956            &mut state,
957            &mut out,
958        )
959        .unwrap_err();
960        assert!(
961            format!("{err}").contains("max_staged_records"),
962            "error must name the cap: {err}"
963        );
964        assert!(matches!(err, FaucetError::Source(_)));
965    }
966
967    #[test]
968    fn no_cap_allows_large_transactions() {
969        // With max_staged_records = None (default) an arbitrarily large
970        // transaction stages without error.
971        let mut registry = RelationRegistry::new();
972        registry.insert(rel_users());
973        let mut state = TxnState::default();
974        let mut out = vec![];
975
976        handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
977        for id in 0..50 {
978            handle_event(
979                xlogdata(insert_payload(
980                    16384,
981                    &[("id", &id.to_string()), ("name", "x")],
982                )),
983                &mut registry,
984                &mut state,
985                &mut out,
986            )
987            .unwrap();
988        }
989        handle_event(
990            commit_event(0x16A_4F88),
991            &mut registry,
992            &mut state,
993            &mut out,
994        )
995        .unwrap();
996        assert_eq!(out.len(), 50);
997    }
998
999    #[test]
1000    fn commit_without_begin_errors() {
1001        let mut registry = RelationRegistry::new();
1002        let mut state = TxnState::default();
1003        let mut out = vec![];
1004
1005        let err = handle_event(
1006            ReplicationEvent::Commit {
1007                lsn: Lsn::from_u64(1),
1008                end_lsn: Lsn::from_u64(2),
1009                commit_time_micros: 0,
1010            },
1011            &mut registry,
1012            &mut state,
1013            &mut out,
1014        )
1015        .unwrap_err();
1016        assert!(format!("{err}").contains("COMMIT without BEGIN"));
1017    }
1018
1019    #[test]
1020    fn double_begin_errors() {
1021        // Regression for #78/#46: a second BEGIN without an intervening COMMIT
1022        // is a stream desync and must abort, not silently discard staged rows.
1023        let mut registry = RelationRegistry::new();
1024        registry.insert(rel_users());
1025        let mut state = TxnState::default();
1026        let mut out = vec![];
1027
1028        handle_event(begin_event(0x100), &mut registry, &mut state, &mut out).unwrap();
1029        handle_event(
1030            xlogdata(insert_payload(16384, &[("id", "1"), ("name", "alice")])),
1031            &mut registry,
1032            &mut state,
1033            &mut out,
1034        )
1035        .unwrap();
1036
1037        let err =
1038            handle_event(begin_event(0x200), &mut registry, &mut state, &mut out).unwrap_err();
1039        assert!(format!("{err}").contains("desync"), "{err}");
1040    }
1041
1042    #[test]
1043    fn unknown_relation_in_insert_errors() {
1044        let mut registry = RelationRegistry::new();
1045        let mut state = TxnState::default();
1046        let mut out = vec![];
1047
1048        handle_event(begin_event(1), &mut registry, &mut state, &mut out).unwrap();
1049        // Insert references relation 99999 which is not in the registry.
1050        let err = handle_event(
1051            xlogdata(insert_payload(99999, &[("id", "1"), ("name", "alice")])),
1052            &mut registry,
1053            &mut state,
1054            &mut out,
1055        )
1056        .unwrap_err();
1057        assert!(format!("{err}").contains("99999"));
1058    }
1059
1060    #[test]
1061    fn update_with_replica_identity_full_emits_before_and_after() {
1062        let mut registry = RelationRegistry::new();
1063        registry.insert(rel_users());
1064        let mut state = TxnState::default();
1065        let mut out = vec![];
1066
1067        handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
1068        handle_event(
1069            xlogdata(update_full_payload(
1070                16384,
1071                &[("id", "1"), ("name", "alice")],
1072                &[("id", "1"), ("name", "alice2")],
1073            )),
1074            &mut registry,
1075            &mut state,
1076            &mut out,
1077        )
1078        .unwrap();
1079        handle_event(
1080            commit_event(0x16A_4F88),
1081            &mut registry,
1082            &mut state,
1083            &mut out,
1084        )
1085        .unwrap();
1086
1087        assert_eq!(out.len(), 1);
1088        assert_eq!(out[0]["op"], "update");
1089        assert_eq!(out[0]["before"]["id"], 1);
1090        assert_eq!(out[0]["before"]["name"], "alice");
1091        assert_eq!(out[0]["after"]["name"], "alice2");
1092    }
1093
1094    #[test]
1095    fn delete_with_replica_identity_full_emits_before_only() {
1096        let mut registry = RelationRegistry::new();
1097        registry.insert(rel_users());
1098        let mut state = TxnState::default();
1099        let mut out = vec![];
1100
1101        handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
1102        handle_event(
1103            xlogdata(delete_full_payload(
1104                16384,
1105                &[("id", "1"), ("name", "alice")],
1106            )),
1107            &mut registry,
1108            &mut state,
1109            &mut out,
1110        )
1111        .unwrap();
1112        handle_event(
1113            commit_event(0x16A_4F88),
1114            &mut registry,
1115            &mut state,
1116            &mut out,
1117        )
1118        .unwrap();
1119
1120        assert_eq!(out.len(), 1);
1121        assert_eq!(out[0]["op"], "delete");
1122        assert_eq!(out[0]["before"]["id"], 1);
1123        assert_eq!(out[0]["before"]["name"], "alice");
1124        assert_eq!(out[0]["after"], Value::Null);
1125    }
1126
1127    #[test]
1128    fn truncate_emits_one_record_per_relation() {
1129        let mut registry = RelationRegistry::new();
1130        registry.insert(rel_users());
1131        // Build a second relation so the truncate-list has two known OIDs.
1132        let mut second = rel_users();
1133        second.oid = 16385;
1134        second.name = "orders".into();
1135        registry.insert(second);
1136
1137        let mut state = TxnState::default();
1138        let mut out = vec![];
1139
1140        handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
1141        handle_event(
1142            xlogdata(truncate_payload(&[16384, 16385])),
1143            &mut registry,
1144            &mut state,
1145            &mut out,
1146        )
1147        .unwrap();
1148        handle_event(
1149            commit_event(0x16A_4F88),
1150            &mut registry,
1151            &mut state,
1152            &mut out,
1153        )
1154        .unwrap();
1155
1156        assert_eq!(out.len(), 2);
1157        assert!(out.iter().all(|r| r["op"] == "truncate"));
1158        let tables: Vec<_> = out.iter().map(|r| r["table"].as_str().unwrap()).collect();
1159        assert!(tables.contains(&"users"));
1160        assert!(tables.contains(&"orders"));
1161    }
1162
1163    #[test]
1164    fn unchanged_toast_in_before_surfaces_via_metadata() {
1165        // Exercise Fix 1: a REPLICA IDENTITY FULL update with an UnchangedToast
1166        // cell in the old tuple must record the column name in
1167        // before.__unchanged_toast__.
1168        let mut registry = RelationRegistry::new();
1169        registry.insert(rel_users());
1170        let mut state = TxnState::default();
1171        let mut out = vec![];
1172
1173        handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
1174        // Hand-build an UPDATE where the OLD tuple's `name` cell is 'u' (unchanged TOAST).
1175        let mut buf: Vec<u8> = Vec::new();
1176        buf.push(b'U');
1177        buf.extend_from_slice(&16384u32.to_be_bytes());
1178        buf.push(b'O');
1179        buf.extend_from_slice(&2u16.to_be_bytes());
1180        // id = text "1"
1181        text_cell(&mut buf, "1");
1182        // name = unchanged TOAST
1183        buf.push(b'u');
1184        // New tuple: id=1, name="alice2"
1185        buf.push(b'N');
1186        buf.extend_from_slice(&2u16.to_be_bytes());
1187        text_cell(&mut buf, "1");
1188        text_cell(&mut buf, "alice2");
1189        handle_event(xlogdata(buf), &mut registry, &mut state, &mut out).unwrap();
1190        handle_event(
1191            commit_event(0x16A_4F88),
1192            &mut registry,
1193            &mut state,
1194            &mut out,
1195        )
1196        .unwrap();
1197
1198        assert_eq!(out.len(), 1);
1199        assert_eq!(out[0]["before"]["__unchanged_toast__"], json!(["name"]));
1200        assert!(out[0]["before"].get("name").is_none());
1201        assert_eq!(out[0]["before"]["id"], 1);
1202        assert_eq!(out[0]["after"]["name"], "alice2");
1203    }
1204
1205    // dataset_uri is a pure-config method; the source requires a live DB to
1206    // construct (async + real PG connection), so we verify the logic directly.
1207    #[test]
1208    fn dataset_uri_strips_credentials() {
1209        let redacted = faucet_core::redact_uri_credentials("postgres://u:p@h:5432/db");
1210        let uri = format!("{}?publication={}", redacted, "my_pub");
1211        assert_eq!(uri, "postgres://h:5432/db?publication=my_pub");
1212    }
1213}