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