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 fn supports_exactly_once(&self) -> bool {
154 true
158 }
159
160 fn connector_name(&self) -> &'static str {
161 "postgres-cdc"
162 }
163
164 fn dataset_uri(&self) -> String {
165 format!(
166 "{}?publication={}",
167 faucet_core::redact_uri_credentials(&self.config.connection_url),
168 self.config.publication_name
169 )
170 }
171
172 async fn check(
188 &self,
189 ctx: &faucet_core::check::CheckContext,
190 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
191 use faucet_core::check::{CheckReport, Probe};
192 use sqlx::ConnectOptions as _;
193 use sqlx::postgres::{PgConnectOptions, PgConnection};
194
195 let start = std::time::Instant::now();
196
197 let opts: PgConnectOptions = match self.config.connection_url.parse() {
199 Ok(o) => o,
200 Err(e) => {
201 return Ok(CheckReport::single(Probe::fail_hint(
202 "auth",
203 start.elapsed(),
204 format!("invalid connection URL: {e}"),
205 "connection_url must be a valid postgres:// URL",
206 )));
207 }
208 };
209
210 let probe = async {
213 let mut conn: PgConnection = opts.connect().await.map_err(|e| {
214 Probe::fail_hint(
215 "auth",
216 start.elapsed(),
217 format!("could not connect: {e}"),
218 "verify the host is reachable and credentials are valid",
219 )
220 })?;
221
222 let row: Option<(String,)> = sqlx::query_as(
223 "SELECT slot_name::text FROM pg_replication_slots WHERE slot_name = $1",
224 )
225 .bind(&self.config.slot_name)
226 .fetch_optional(&mut conn)
227 .await
228 .map_err(|e| {
229 Probe::fail(
230 "slot",
231 start.elapsed(),
232 format!("could not query pg_replication_slots: {e}"),
233 )
234 })?;
235
236 Ok::<Probe, Probe>(match row {
237 Some(_) => Probe::pass("slot", start.elapsed()),
238 None => Probe::skip(
239 "slot",
240 format!(
241 "replication slot {} does not exist yet (faucet run can create it)",
242 self.config.slot_name
243 ),
244 ),
245 })
246 };
247
248 let probe = match tokio::time::timeout(ctx.timeout, probe).await {
249 Ok(Ok(p)) | Ok(Err(p)) => p,
250 Err(_elapsed) => Probe::fail_hint(
251 "auth",
252 start.elapsed(),
253 "connection timed out",
254 "the database did not respond within the check timeout",
255 ),
256 };
257 Ok(CheckReport::single(probe))
258 }
259}
260
261impl PostgresCdcSource {
262 fn stream_pages_with_batch_size<'a>(
273 &'a self,
274 _ctx: &'a HashMap<String, Value>,
275 batch_size: usize,
276 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
277 let max_messages = self.config.max_messages.unwrap_or(usize::MAX);
278 let idle_timeout = self.config.idle_timeout;
279 let per_transaction = batch_size != 0;
280
281 Box::pin(async_stream::try_stream! {
282 let pending = {
284 let mut g = self.pending_bookmark.lock().await;
285 g.take()
286 };
287 let start_lsn = if let Some(b) = pending.as_ref() {
288 let lsn = b.as_u64()?;
289 *self.confirmed_lsn.lock().await = lsn;
290 Some(lsn)
291 } else {
292 self.config
293 .start_lsn
294 .as_deref()
295 .map(parse_lsn)
296 .transpose()?
297 };
298
299 let params = ReplicationParams {
301 connection_url: &self.config.connection_url,
302 slot_name: &self.config.slot_name,
303 publication_name: &self.config.publication_name,
304 proto_version: self.config.proto_version,
305 create_slot_if_missing: self.config.create_slot_if_missing,
306 start_lsn,
307 status_update_interval: self.config.status_update_interval,
308 tcp_keepalive: self.config.tcp_keepalive,
309 slot_type: self.config.slot_type,
310 tls: &self.config.tls,
311 };
312 let client = replication::connect(¶ms).await?;
313 replication::ensure_slot(
314 &client,
315 &self.config.connection_url,
316 &self.config.slot_name,
317 self.config.create_slot_if_missing,
318 self.config.slot_type,
319 )
320 .await?;
321
322 if let Some(lsn) = start_lsn {
333 replication::retry_on_slot_active(self.config.slot_acquire_retries, || {
338 replication::advance_slot(
339 &self.config.connection_url,
340 &self.config.slot_name,
341 lsn,
342 )
343 })
344 .await?;
345 }
346
347 let mut duplex = replication::retry_on_slot_active(
348 self.config.slot_acquire_retries,
349 || replication::start_replication(&client, ¶ms),
350 )
351 .await?;
352
353 let initial_confirmed = *self.confirmed_lsn.lock().await;
356 send_status_update(&mut duplex, initial_confirmed, false).await?;
357
358 let mut registry = RelationRegistry::new();
365 let mut state = TxnState {
366 max_staged_records: self.config.max_staged_records,
367 ..TxnState::default()
368 };
369 let mut agg_records: Vec<Value> = Vec::new();
370 let mut total_records: usize = 0;
371 let mut last_message_at = Instant::now();
372
373 loop {
374 let idle_deadline = last_message_at + idle_timeout;
375 let budget = idle_deadline
376 .checked_duration_since(Instant::now())
377 .unwrap_or(Duration::ZERO);
378
379 let mut stop = false;
383 let mut just_committed: Option<(u64, Vec<Value>)> = None;
384 let mut fatal: Option<FaucetError> = None;
385 let mut unexpected_end = false;
386 tokio::select! {
387 biased;
388 _ = tokio::signal::ctrl_c() => {
389 tracing::info!("postgres-cdc: ctrl_c received, stopping cleanly");
390 stop = true;
391 }
392 ev = tokio::time::timeout(budget, recv(&mut duplex)) => {
393 match ev {
394 Ok(Ok(Some(event))) => {
395 last_message_at = Instant::now();
399 let was_in_txn = state.in_txn;
400 let pre_commit_count = state.last_committed;
401 let mut committed_records: Vec<Value> = Vec::new();
402 if let Err(e) = handle_event(
403 event,
404 &mut registry,
405 &mut state,
406 &mut committed_records,
407 ) {
408 fatal = Some(e);
409 } else if was_in_txn
410 && !state.in_txn
411 && state.last_committed != pre_commit_count
412 {
413 let lsn = state.last_committed
417 .expect("last_committed set on commit");
418 total_records += committed_records.len();
419 just_committed = Some((lsn, committed_records));
420 }
421 }
422 Ok(Ok(None)) => {
423 unexpected_end = true;
424 }
425 Ok(Err(e)) => {
426 fatal = Some(e);
427 }
428 Err(_timeout) => {
429 tracing::debug!(
430 "postgres-cdc: idle_timeout reached, stopping"
431 );
432 stop = true;
433 }
434 }
435 }
436 }
437
438 if let Some(e) = fatal {
439 Err(e)?;
440 }
441 if unexpected_end {
442 Err(FaucetError::Source(
443 "postgres-cdc: replication stream ended unexpectedly".into(),
444 ))?;
445 }
446 if let Some((lsn, drained)) = just_committed {
447 if per_transaction {
458 let bookmark = Some(Bookmark::from_u64(lsn).to_value()?);
459 yield StreamPage {
460 records: drained,
461 bookmark,
462 };
463 } else {
464 agg_records.extend(drained);
465 }
466 if total_records >= max_messages {
467 stop = true;
468 }
469 }
470
471 if stop {
472 break;
473 }
474 }
475
476 if !per_transaction
482 && let Some(lsn) = state.last_committed
483 {
484 let bookmark = Some(Bookmark::from_u64(lsn).to_value()?);
485 yield StreamPage {
486 records: agg_records,
487 bookmark,
488 };
489 }
490
491 tracing::info!(
492 records = total_records,
493 batch_size,
494 "postgres-cdc: stream complete",
495 );
496 })
497 }
498}
499
500struct TupleRow {
503 values: Map<String, Value>,
504 unchanged_toast: Vec<String>,
505}
506
507#[derive(Default)]
509struct TxnState {
510 staged: Vec<Value>,
515 last_committed: Option<u64>,
517 in_progress_ts: i64,
520 in_progress_lsn: u64,
522 in_txn: bool,
524 max_staged_records: Option<usize>,
529}
530
531impl TxnState {
532 fn push_staged(&mut self, record: Value) -> Result<(), FaucetError> {
535 if let Some(max) = self.max_staged_records
536 && self.staged.len() >= max
537 {
538 return Err(FaucetError::Source(format!(
539 "postgres-cdc: in-progress transaction exceeded max_staged_records ({max}); \
540 aborting to avoid unbounded memory growth. Raise max_staged_records or \
541 reduce the size of the source transaction."
542 )));
543 }
544 self.staged.push(record);
545 Ok(())
546 }
547}
548
549fn handle_event(
550 event: ReplicationEvent,
551 registry: &mut RelationRegistry,
552 state: &mut TxnState,
553 out: &mut Vec<Value>,
554) -> Result<(), FaucetError> {
555 match event {
556 ReplicationEvent::Begin {
557 final_lsn,
558 commit_time_micros,
559 xid: _,
560 } => {
561 if state.in_txn {
562 return Err(FaucetError::Source(format!(
567 "postgres-cdc: BEGIN received while a previous transaction was still \
568 in progress ({} records staged) — replication stream desync",
569 state.staged.len()
570 )));
571 }
572 state.in_txn = true;
573 state.in_progress_lsn = final_lsn.as_u64();
574 state.in_progress_ts = commit_time_micros;
575 state.staged.clear();
576 }
577 ReplicationEvent::Commit {
578 lsn: _,
579 commit_time_micros: _,
580 end_lsn,
581 } => {
582 if !state.in_txn {
583 return Err(FaucetError::Source(
584 "postgres-cdc: COMMIT without BEGIN".into(),
585 ));
586 }
587 out.append(&mut state.staged);
605 state.last_committed = Some(end_lsn.as_u64());
606 state.in_txn = false;
607 }
608 ReplicationEvent::XLogData { data, .. } => {
609 let msg = decode_message(&data)?;
610 handle_pgoutput(msg, registry, state)?;
611 }
612 ReplicationEvent::Message { .. } => {
613 }
616 other => {
620 return Err(FaucetError::Source(format!(
621 "postgres-cdc: unhandled ReplicationEvent variant {other:?} — refusing to \
622 continue rather than risk silently dropping change data"
623 )));
624 }
625 }
626 Ok(())
627}
628
629fn handle_pgoutput(
630 msg: Message,
631 registry: &mut RelationRegistry,
632 state: &mut TxnState,
633) -> Result<(), FaucetError> {
634 match msg {
635 Message::Relation(r) => registry.insert(r),
636 Message::Origin | Message::Type => {} Message::Insert(i) => stage_insert(state, registry, i)?,
638 Message::Update(u) => stage_update(state, registry, u)?,
639 Message::Delete(d) => stage_delete(state, registry, d)?,
640 Message::Truncate(t) => stage_truncate(state, registry, t)?,
641 Message::Begin(_) | Message::Commit(_) => {
646 tracing::warn!(
647 "postgres-cdc: pgoutput Begin/Commit reached pgoutput decoder; \
648 pgwire-replication should have intercepted it"
649 );
650 }
651 }
652 Ok(())
653}
654
655fn stage_insert(
656 state: &mut TxnState,
657 registry: &RelationRegistry,
658 i: Insert,
659) -> Result<(), FaucetError> {
660 let rel = registry.get(i.relation_oid)?;
661 let after = tuple_to_object(rel, &i.new)?;
662 let r = record(rel, "insert", state, None, Some(after));
663 state.push_staged(r)
664}
665
666fn stage_update(
667 state: &mut TxnState,
668 registry: &RelationRegistry,
669 u: Update,
670) -> Result<(), FaucetError> {
671 let rel = registry.get(u.relation_oid)?;
672 let before = match &u.old {
673 Some(t) => Some(tuple_to_object(rel, t)?),
674 None => None,
675 };
676 let after = tuple_to_object(rel, &u.new)?;
677 let r = record(rel, "update", state, before, Some(after));
678 state.push_staged(r)
679}
680
681fn stage_delete(
682 state: &mut TxnState,
683 registry: &RelationRegistry,
684 d: Delete,
685) -> Result<(), FaucetError> {
686 let rel = registry.get(d.relation_oid)?;
687 let before = Some(tuple_to_object(rel, &d.old)?);
688 let r = record(rel, "delete", state, before, None);
689 state.push_staged(r)
690}
691
692fn stage_truncate(
693 state: &mut TxnState,
694 registry: &RelationRegistry,
695 t: Truncate,
696) -> Result<(), FaucetError> {
697 for oid in &t.relation_oids {
698 let rel = registry.get(*oid)?;
699 let r = record(rel, "truncate", state, None, None);
700 state.push_staged(r)?;
701 }
702 Ok(())
703}
704
705fn record(
706 rel: &Relation,
707 op: &str,
708 state: &TxnState,
709 before: Option<TupleRow>,
710 after: Option<TupleRow>,
711) -> Value {
712 fn to_value(row: TupleRow) -> Value {
713 let mut o = row.values;
714 if !row.unchanged_toast.is_empty() {
715 o.insert("__unchanged_toast__".into(), json!(row.unchanged_toast));
716 }
717 Value::Object(o)
718 }
719 let mut obj = Map::new();
720 obj.insert("op".into(), json!(op));
721 obj.insert("schema".into(), json!(rel.namespace));
722 obj.insert("table".into(), json!(rel.name));
723 obj.insert("lsn".into(), json!(format_lsn(state.in_progress_lsn)));
724 obj.insert(
725 "ts_ms".into(),
726 json!(postgres_clock_to_unix_ms(state.in_progress_ts)),
727 );
728 obj.insert("before".into(), before.map(to_value).unwrap_or(Value::Null));
729 obj.insert("after".into(), after.map(to_value).unwrap_or(Value::Null));
730 Value::Object(obj)
731}
732
733fn tuple_to_object(rel: &Relation, tup: &TupleData) -> Result<TupleRow, FaucetError> {
735 if tup.cells.len() != rel.columns.len() {
736 return Err(FaucetError::Source(format!(
737 "postgres-cdc: tuple has {} cells but relation {}.{} has {} columns",
738 tup.cells.len(),
739 rel.namespace,
740 rel.name,
741 rel.columns.len()
742 )));
743 }
744 let mut values = Map::with_capacity(rel.columns.len());
745 let mut unchanged_toast = Vec::new();
746 for (col, cell) in rel.columns.iter().zip(&tup.cells) {
747 match cell {
748 TupleCell::Null => {
749 values.insert(col.name.clone(), Value::Null);
750 }
751 TupleCell::UnchangedToast => {
752 unchanged_toast.push(col.name.clone());
753 }
754 TupleCell::Text(s) => {
755 values.insert(col.name.clone(), text_to_json(col.type_oid, s)?);
756 }
757 }
758 }
759 Ok(TupleRow {
760 values,
761 unchanged_toast,
762 })
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768 use crate::pgoutput::messages::{ColumnDesc, ReplicaIdentity};
769 use crate::replication::ReplicationEvent;
770 use pgwire_replication::Lsn;
771
772 fn rel_users() -> Relation {
773 Relation {
774 oid: 16384,
775 namespace: "public".into(),
776 name: "users".into(),
777 replica_identity: ReplicaIdentity::Default,
778 columns: vec![
779 ColumnDesc {
780 flags: 1,
781 name: "id".into(),
782 type_oid: 23,
783 type_modifier: -1,
784 },
785 ColumnDesc {
786 flags: 0,
787 name: "name".into(),
788 type_oid: 25,
789 type_modifier: -1,
790 },
791 ],
792 }
793 }
794
795 fn xlogdata(payload: Vec<u8>) -> ReplicationEvent {
796 ReplicationEvent::XLogData {
797 wal_start: Lsn::from_u64(0),
798 wal_end: Lsn::from_u64(0x16A_4F88),
799 server_time_micros: 0,
800 data: bytes::Bytes::from(payload),
801 }
802 }
803
804 fn insert_payload(relation_oid: u32, cells: &[(&str, &str)]) -> Vec<u8> {
805 let mut buf: Vec<u8> = Vec::new();
806 buf.push(b'I');
807 buf.extend_from_slice(&relation_oid.to_be_bytes());
808 buf.push(b'N');
809 buf.extend_from_slice(&(cells.len() as u16).to_be_bytes());
810 for (_, val) in cells {
811 text_cell(&mut buf, val);
812 }
813 buf
814 }
815
816 fn update_full_payload(
818 relation_oid: u32,
819 old_cells: &[(&str, &str)],
820 new_cells: &[(&str, &str)],
821 ) -> Vec<u8> {
822 let mut buf: Vec<u8> = Vec::new();
823 buf.push(b'U');
824 buf.extend_from_slice(&relation_oid.to_be_bytes());
825 buf.push(b'O');
826 buf.extend_from_slice(&(old_cells.len() as u16).to_be_bytes());
827 for (_, val) in old_cells {
828 text_cell(&mut buf, val);
829 }
830 buf.push(b'N');
831 buf.extend_from_slice(&(new_cells.len() as u16).to_be_bytes());
832 for (_, val) in new_cells {
833 text_cell(&mut buf, val);
834 }
835 buf
836 }
837
838 fn delete_full_payload(relation_oid: u32, old_cells: &[(&str, &str)]) -> Vec<u8> {
840 let mut buf: Vec<u8> = Vec::new();
841 buf.push(b'D');
842 buf.extend_from_slice(&relation_oid.to_be_bytes());
843 buf.push(b'O');
844 buf.extend_from_slice(&(old_cells.len() as u16).to_be_bytes());
845 for (_, val) in old_cells {
846 text_cell(&mut buf, val);
847 }
848 buf
849 }
850
851 fn truncate_payload(relation_oids: &[u32]) -> Vec<u8> {
853 let mut buf: Vec<u8> = Vec::new();
854 buf.push(b'T');
855 buf.extend_from_slice(&(relation_oids.len() as u32).to_be_bytes());
856 buf.push(0u8); for oid in relation_oids {
858 buf.extend_from_slice(&oid.to_be_bytes());
859 }
860 buf
861 }
862
863 fn text_cell(buf: &mut Vec<u8>, val: &str) {
864 buf.push(b't');
865 buf.extend_from_slice(&(val.len() as u32).to_be_bytes());
866 buf.extend_from_slice(val.as_bytes());
867 }
868
869 fn begin_event(final_lsn: u64) -> ReplicationEvent {
870 ReplicationEvent::Begin {
871 final_lsn: Lsn::from_u64(final_lsn),
872 xid: 1,
873 commit_time_micros: 0,
874 }
875 }
876
877 fn commit_event(lsn: u64) -> ReplicationEvent {
878 ReplicationEvent::Commit {
879 lsn: Lsn::from_u64(lsn),
880 end_lsn: Lsn::from_u64(lsn + 0x10),
881 commit_time_micros: 0,
882 }
883 }
884
885 #[test]
886 fn full_transaction_promotes_to_output_on_commit() {
887 let mut registry = RelationRegistry::new();
888 registry.insert(rel_users());
889 let mut state = TxnState::default();
890 let mut out = vec![];
891
892 handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
893 assert!(out.is_empty());
894
895 handle_event(
896 xlogdata(insert_payload(16384, &[("id", "1"), ("name", "alice")])),
897 &mut registry,
898 &mut state,
899 &mut out,
900 )
901 .unwrap();
902 assert!(out.is_empty(), "records stay staged until COMMIT");
903
904 handle_event(
905 commit_event(0x16A_4F88),
906 &mut registry,
907 &mut state,
908 &mut out,
909 )
910 .unwrap();
911
912 assert_eq!(out.len(), 1);
913 assert_eq!(out[0]["op"], "insert");
914 assert_eq!(out[0]["schema"], "public");
915 assert_eq!(out[0]["table"], "users");
916 assert_eq!(out[0]["lsn"], "0/16A4F88");
917 assert_eq!(out[0]["after"]["id"], 1);
918 assert_eq!(out[0]["after"]["name"], "alice");
919 assert_eq!(out[0]["before"], Value::Null);
920
921 assert_eq!(state.last_committed, Some(0x16A_4F88 + 0x10));
926 }
927
928 #[test]
929 fn staging_beyond_max_staged_records_aborts() {
930 let mut registry = RelationRegistry::new();
934 registry.insert(rel_users());
935 let mut state = TxnState {
936 max_staged_records: Some(2),
937 ..TxnState::default()
938 };
939 let mut out = vec![];
940
941 handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
942 for id in ["1", "2"] {
944 handle_event(
945 xlogdata(insert_payload(16384, &[("id", id), ("name", "x")])),
946 &mut registry,
947 &mut state,
948 &mut out,
949 )
950 .unwrap();
951 }
952 let err = handle_event(
954 xlogdata(insert_payload(16384, &[("id", "3"), ("name", "x")])),
955 &mut registry,
956 &mut state,
957 &mut out,
958 )
959 .unwrap_err();
960 assert!(
961 format!("{err}").contains("max_staged_records"),
962 "error must name the cap: {err}"
963 );
964 assert!(matches!(err, FaucetError::Source(_)));
965 }
966
967 #[test]
968 fn no_cap_allows_large_transactions() {
969 let mut registry = RelationRegistry::new();
972 registry.insert(rel_users());
973 let mut state = TxnState::default();
974 let mut out = vec![];
975
976 handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
977 for id in 0..50 {
978 handle_event(
979 xlogdata(insert_payload(
980 16384,
981 &[("id", &id.to_string()), ("name", "x")],
982 )),
983 &mut registry,
984 &mut state,
985 &mut out,
986 )
987 .unwrap();
988 }
989 handle_event(
990 commit_event(0x16A_4F88),
991 &mut registry,
992 &mut state,
993 &mut out,
994 )
995 .unwrap();
996 assert_eq!(out.len(), 50);
997 }
998
999 #[test]
1000 fn commit_without_begin_errors() {
1001 let mut registry = RelationRegistry::new();
1002 let mut state = TxnState::default();
1003 let mut out = vec![];
1004
1005 let err = handle_event(
1006 ReplicationEvent::Commit {
1007 lsn: Lsn::from_u64(1),
1008 end_lsn: Lsn::from_u64(2),
1009 commit_time_micros: 0,
1010 },
1011 &mut registry,
1012 &mut state,
1013 &mut out,
1014 )
1015 .unwrap_err();
1016 assert!(format!("{err}").contains("COMMIT without BEGIN"));
1017 }
1018
1019 #[test]
1020 fn double_begin_errors() {
1021 let mut registry = RelationRegistry::new();
1024 registry.insert(rel_users());
1025 let mut state = TxnState::default();
1026 let mut out = vec![];
1027
1028 handle_event(begin_event(0x100), &mut registry, &mut state, &mut out).unwrap();
1029 handle_event(
1030 xlogdata(insert_payload(16384, &[("id", "1"), ("name", "alice")])),
1031 &mut registry,
1032 &mut state,
1033 &mut out,
1034 )
1035 .unwrap();
1036
1037 let err =
1038 handle_event(begin_event(0x200), &mut registry, &mut state, &mut out).unwrap_err();
1039 assert!(format!("{err}").contains("desync"), "{err}");
1040 }
1041
1042 #[test]
1043 fn unknown_relation_in_insert_errors() {
1044 let mut registry = RelationRegistry::new();
1045 let mut state = TxnState::default();
1046 let mut out = vec![];
1047
1048 handle_event(begin_event(1), &mut registry, &mut state, &mut out).unwrap();
1049 let err = handle_event(
1051 xlogdata(insert_payload(99999, &[("id", "1"), ("name", "alice")])),
1052 &mut registry,
1053 &mut state,
1054 &mut out,
1055 )
1056 .unwrap_err();
1057 assert!(format!("{err}").contains("99999"));
1058 }
1059
1060 #[test]
1061 fn update_with_replica_identity_full_emits_before_and_after() {
1062 let mut registry = RelationRegistry::new();
1063 registry.insert(rel_users());
1064 let mut state = TxnState::default();
1065 let mut out = vec![];
1066
1067 handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
1068 handle_event(
1069 xlogdata(update_full_payload(
1070 16384,
1071 &[("id", "1"), ("name", "alice")],
1072 &[("id", "1"), ("name", "alice2")],
1073 )),
1074 &mut registry,
1075 &mut state,
1076 &mut out,
1077 )
1078 .unwrap();
1079 handle_event(
1080 commit_event(0x16A_4F88),
1081 &mut registry,
1082 &mut state,
1083 &mut out,
1084 )
1085 .unwrap();
1086
1087 assert_eq!(out.len(), 1);
1088 assert_eq!(out[0]["op"], "update");
1089 assert_eq!(out[0]["before"]["id"], 1);
1090 assert_eq!(out[0]["before"]["name"], "alice");
1091 assert_eq!(out[0]["after"]["name"], "alice2");
1092 }
1093
1094 #[test]
1095 fn delete_with_replica_identity_full_emits_before_only() {
1096 let mut registry = RelationRegistry::new();
1097 registry.insert(rel_users());
1098 let mut state = TxnState::default();
1099 let mut out = vec![];
1100
1101 handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
1102 handle_event(
1103 xlogdata(delete_full_payload(
1104 16384,
1105 &[("id", "1"), ("name", "alice")],
1106 )),
1107 &mut registry,
1108 &mut state,
1109 &mut out,
1110 )
1111 .unwrap();
1112 handle_event(
1113 commit_event(0x16A_4F88),
1114 &mut registry,
1115 &mut state,
1116 &mut out,
1117 )
1118 .unwrap();
1119
1120 assert_eq!(out.len(), 1);
1121 assert_eq!(out[0]["op"], "delete");
1122 assert_eq!(out[0]["before"]["id"], 1);
1123 assert_eq!(out[0]["before"]["name"], "alice");
1124 assert_eq!(out[0]["after"], Value::Null);
1125 }
1126
1127 #[test]
1128 fn truncate_emits_one_record_per_relation() {
1129 let mut registry = RelationRegistry::new();
1130 registry.insert(rel_users());
1131 let mut second = rel_users();
1133 second.oid = 16385;
1134 second.name = "orders".into();
1135 registry.insert(second);
1136
1137 let mut state = TxnState::default();
1138 let mut out = vec![];
1139
1140 handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
1141 handle_event(
1142 xlogdata(truncate_payload(&[16384, 16385])),
1143 &mut registry,
1144 &mut state,
1145 &mut out,
1146 )
1147 .unwrap();
1148 handle_event(
1149 commit_event(0x16A_4F88),
1150 &mut registry,
1151 &mut state,
1152 &mut out,
1153 )
1154 .unwrap();
1155
1156 assert_eq!(out.len(), 2);
1157 assert!(out.iter().all(|r| r["op"] == "truncate"));
1158 let tables: Vec<_> = out.iter().map(|r| r["table"].as_str().unwrap()).collect();
1159 assert!(tables.contains(&"users"));
1160 assert!(tables.contains(&"orders"));
1161 }
1162
1163 #[test]
1164 fn unchanged_toast_in_before_surfaces_via_metadata() {
1165 let mut registry = RelationRegistry::new();
1169 registry.insert(rel_users());
1170 let mut state = TxnState::default();
1171 let mut out = vec![];
1172
1173 handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
1174 let mut buf: Vec<u8> = Vec::new();
1176 buf.push(b'U');
1177 buf.extend_from_slice(&16384u32.to_be_bytes());
1178 buf.push(b'O');
1179 buf.extend_from_slice(&2u16.to_be_bytes());
1180 text_cell(&mut buf, "1");
1182 buf.push(b'u');
1184 buf.push(b'N');
1186 buf.extend_from_slice(&2u16.to_be_bytes());
1187 text_cell(&mut buf, "1");
1188 text_cell(&mut buf, "alice2");
1189 handle_event(xlogdata(buf), &mut registry, &mut state, &mut out).unwrap();
1190 handle_event(
1191 commit_event(0x16A_4F88),
1192 &mut registry,
1193 &mut state,
1194 &mut out,
1195 )
1196 .unwrap();
1197
1198 assert_eq!(out.len(), 1);
1199 assert_eq!(out[0]["before"]["__unchanged_toast__"], json!(["name"]));
1200 assert!(out[0]["before"].get("name").is_none());
1201 assert_eq!(out[0]["before"]["id"], 1);
1202 assert_eq!(out[0]["after"]["name"], "alice2");
1203 }
1204
1205 #[test]
1208 fn dataset_uri_strips_credentials() {
1209 let redacted = faucet_core::redact_uri_credentials("postgres://u:p@h:5432/db");
1210 let uri = format!("{}?publication={}", redacted, "my_pub");
1211 assert_eq!(uri, "postgres://h:5432/db?publication=my_pub");
1212 }
1213}