1use 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 pending_bookmark: Mutex<Option<Bookmark>>,
29 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 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 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 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 *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 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 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 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 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 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 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 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 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(¶ms).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 if let Some(lsn) = start_lsn {
354 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, ¶ms),
371 )
372 .await?;
373
374 let initial_confirmed = *self.confirmed_lsn.lock().await;
377 send_status_update(&mut duplex, initial_confirmed, false).await?;
378
379 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 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 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 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 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 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
521struct TupleRow {
524 values: Map<String, Value>,
525 unchanged_toast: Vec<String>,
526}
527
528#[derive(Default)]
530struct TxnState {
531 staged: Vec<Value>,
536 last_committed: Option<u64>,
538 in_progress_ts: i64,
541 in_progress_lsn: u64,
543 in_txn: bool,
545 max_staged_records: Option<usize>,
550}
551
552impl TxnState {
553 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 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 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 }
637 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 => {} 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 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
754fn 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 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 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 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); 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 assert_eq!(state.last_committed, Some(0x16A_4F88 + 0x10));
947 }
948
949 #[test]
950 fn staging_beyond_max_staged_records_aborts() {
951 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 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 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 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 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 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 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 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 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 text_cell(&mut buf, "1");
1203 buf.push(b'u');
1205 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 #[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}