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