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(
63 &self.config.connection_url,
64 &self.config.slot_name,
65 &self.config.tls,
66 )
67 .await
68 }
69}
70
71#[async_trait]
72impl Source for PostgresCdcSource {
73 async fn fetch_with_context(
74 &self,
75 ctx: &HashMap<String, Value>,
76 ) -> Result<Vec<Value>, FaucetError> {
77 let (records, _bookmark) = self.fetch_with_context_incremental(ctx).await?;
78 Ok(records)
79 }
80
81 async fn fetch_with_context_incremental(
93 &self,
94 ctx: &HashMap<String, Value>,
95 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
96 use futures::StreamExt;
97 let mut pages = self.stream_pages_with_batch_size(ctx, 0);
98 let mut all: Vec<Value> = Vec::new();
99 let mut bookmark: Option<Value> = None;
100 while let Some(page) = pages.next().await {
101 let page = page?;
102 all.extend(page.records);
103 if page.bookmark.is_some() {
104 bookmark = page.bookmark;
105 }
106 }
107 Ok((all, bookmark))
108 }
109
110 fn stream_pages<'a>(
133 &'a self,
134 ctx: &'a HashMap<String, Value>,
135 _batch_size: usize,
136 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
137 self.stream_pages_with_batch_size(ctx, self.config.batch_size)
138 }
139
140 fn config_schema(&self) -> Value {
141 let schema = schemars::schema_for!(PostgresCdcSourceConfig);
142 serde_json::to_value(&schema).unwrap_or(Value::Null)
143 }
144
145 fn state_key(&self) -> Option<String> {
146 Some(self.state_key_value.clone())
147 }
148
149 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
150 let parsed = Bookmark::from_value(bookmark)?;
151 *self.confirmed_lsn.lock().await = parsed.as_u64()?;
154 *self.pending_bookmark.lock().await = Some(parsed);
155 Ok(())
156 }
157
158 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
159 if matches!(self.config.slot_type, crate::config::SlotType::Temporary) {
162 return Err(FaucetError::Config(
163 "postgres-cdc: replication snapshot handoff requires a permanent slot \
164 (slot_type: permanent) so WAL is retained across the snapshot — a \
165 temporary slot is dropped when the capture connection closes"
166 .into(),
167 ));
168 }
169 let lsn = replication::ensure_slot_and_current_lsn(
170 &self.config.connection_url,
171 &self.config.slot_name,
172 self.config.create_slot_if_missing,
173 self.config.slot_type,
174 &self.config.tls,
175 )
176 .await?;
177 Ok(Some(crate::state::Bookmark::from_u64(lsn).to_value()?))
178 }
179
180 fn supports_exactly_once(&self) -> bool {
181 true
185 }
186
187 fn connector_name(&self) -> &'static str {
188 "postgres-cdc"
189 }
190
191 fn dataset_uri(&self) -> String {
192 format!(
193 "{}?publication={}",
194 faucet_core::redact_uri_credentials(&self.config.connection_url),
195 self.config.publication_name
196 )
197 }
198
199 async fn check(
215 &self,
216 ctx: &faucet_core::check::CheckContext,
217 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
218 use faucet_core::check::{CheckReport, Probe};
219 use sqlx::ConnectOptions as _;
220 use sqlx::postgres::{PgConnectOptions, PgConnection};
221
222 let start = std::time::Instant::now();
223
224 let opts: PgConnectOptions = match self.config.connection_url.parse() {
226 Ok(o) => o,
227 Err(e) => {
228 return Ok(CheckReport::single(Probe::fail_hint(
229 "auth",
230 start.elapsed(),
231 format!("invalid connection URL: {e}"),
232 "connection_url must be a valid postgres:// URL",
233 )));
234 }
235 };
236
237 let probe = async {
240 let mut conn: PgConnection = opts.connect().await.map_err(|e| {
241 Probe::fail_hint(
242 "auth",
243 start.elapsed(),
244 format!("could not connect: {e}"),
245 "verify the host is reachable and credentials are valid",
246 )
247 })?;
248
249 let row: Option<(String,)> = sqlx::query_as(
250 "SELECT slot_name::text FROM pg_replication_slots WHERE slot_name = $1",
251 )
252 .bind(&self.config.slot_name)
253 .fetch_optional(&mut conn)
254 .await
255 .map_err(|e| {
256 Probe::fail(
257 "slot",
258 start.elapsed(),
259 format!("could not query pg_replication_slots: {e}"),
260 )
261 })?;
262
263 Ok::<Probe, Probe>(match row {
264 Some(_) => Probe::pass("slot", start.elapsed()),
265 None => Probe::skip(
266 "slot",
267 format!(
268 "replication slot {} does not exist yet (faucet run can create it)",
269 self.config.slot_name
270 ),
271 ),
272 })
273 };
274
275 let probe = match tokio::time::timeout(ctx.timeout, probe).await {
276 Ok(Ok(p)) | Ok(Err(p)) => p,
277 Err(_elapsed) => Probe::fail_hint(
278 "auth",
279 start.elapsed(),
280 "connection timed out",
281 "the database did not respond within the check timeout",
282 ),
283 };
284 Ok(CheckReport::single(probe))
285 }
286}
287
288impl PostgresCdcSource {
289 fn stream_pages_with_batch_size<'a>(
300 &'a self,
301 _ctx: &'a HashMap<String, Value>,
302 batch_size: usize,
303 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
304 let max_messages = self.config.max_messages.unwrap_or(usize::MAX);
305 let idle_timeout = self.config.idle_timeout;
306 let per_transaction = batch_size != 0;
307
308 Box::pin(async_stream::try_stream! {
309 let pending = {
311 let mut g = self.pending_bookmark.lock().await;
312 g.take()
313 };
314 let start_lsn = if let Some(b) = pending.as_ref() {
315 let lsn = b.as_u64()?;
316 *self.confirmed_lsn.lock().await = lsn;
317 Some(lsn)
318 } else {
319 self.config
320 .start_lsn
321 .as_deref()
322 .map(parse_lsn)
323 .transpose()?
324 };
325
326 let params = ReplicationParams {
328 connection_url: &self.config.connection_url,
329 slot_name: &self.config.slot_name,
330 publication_name: &self.config.publication_name,
331 proto_version: self.config.proto_version,
332 create_slot_if_missing: self.config.create_slot_if_missing,
333 start_lsn,
334 status_update_interval: self.config.status_update_interval,
335 tcp_keepalive: self.config.tcp_keepalive,
336 slot_type: self.config.slot_type,
337 tls: &self.config.tls,
338 };
339 let client = replication::connect(¶ms).await?;
340 replication::ensure_slot(
341 &client,
342 &self.config.connection_url,
343 &self.config.slot_name,
344 self.config.create_slot_if_missing,
345 self.config.slot_type,
346 &self.config.tls,
347 )
348 .await?;
349
350 if let Some(lsn) = start_lsn {
361 replication::retry_on_slot_active(self.config.slot_acquire_retries, || {
366 replication::advance_slot(
367 &self.config.connection_url,
368 &self.config.slot_name,
369 lsn,
370 &self.config.tls,
371 )
372 })
373 .await?;
374 }
375
376 let mut duplex = replication::retry_on_slot_active(
377 self.config.slot_acquire_retries,
378 || replication::start_replication(&client, ¶ms),
379 )
380 .await?;
381
382 let initial_confirmed = *self.confirmed_lsn.lock().await;
385 send_status_update(&mut duplex, initial_confirmed, false).await?;
386
387 let mut registry = RelationRegistry::new();
394 let mut state = TxnState {
395 max_staged_records: self.config.max_staged_records,
396 ..TxnState::default()
397 };
398 let mut agg_records: Vec<Value> = Vec::new();
399 let mut total_records: usize = 0;
400 let mut last_message_at = Instant::now();
401
402 loop {
403 let idle_deadline = last_message_at + idle_timeout;
404 let budget = idle_deadline
405 .checked_duration_since(Instant::now())
406 .unwrap_or(Duration::ZERO);
407
408 let mut stop = false;
412 let mut just_committed: Option<(u64, Vec<Value>)> = None;
413 let mut fatal: Option<FaucetError> = None;
414 let mut unexpected_end = false;
415 tokio::select! {
416 biased;
417 _ = tokio::signal::ctrl_c() => {
418 tracing::info!("postgres-cdc: ctrl_c received, stopping cleanly");
419 stop = true;
420 }
421 ev = tokio::time::timeout(budget, recv(&mut duplex)) => {
422 match ev {
423 Ok(Ok(Some(event))) => {
424 last_message_at = Instant::now();
428 let was_in_txn = state.in_txn;
429 let pre_commit_count = state.last_committed;
430 let mut committed_records: Vec<Value> = Vec::new();
431 if let Err(e) = handle_event(
432 event,
433 &mut registry,
434 &mut state,
435 &mut committed_records,
436 ) {
437 fatal = Some(e);
438 } else if was_in_txn
439 && !state.in_txn
440 && state.last_committed != pre_commit_count
441 {
442 let lsn = state.last_committed
446 .expect("last_committed set on commit");
447 total_records += committed_records.len();
448 just_committed = Some((lsn, committed_records));
449 }
450 }
451 Ok(Ok(None)) => {
452 unexpected_end = true;
453 }
454 Ok(Err(e)) => {
455 fatal = Some(e);
456 }
457 Err(_timeout) => {
458 tracing::debug!(
459 "postgres-cdc: idle_timeout reached, stopping"
460 );
461 stop = true;
462 }
463 }
464 }
465 }
466
467 if let Some(e) = fatal {
468 Err(e)?;
469 }
470 if unexpected_end {
471 Err(FaucetError::Source(
472 "postgres-cdc: replication stream ended unexpectedly".into(),
473 ))?;
474 }
475 if let Some((lsn, drained)) = just_committed {
476 if per_transaction {
487 let bookmark = Some(Bookmark::from_u64(lsn).to_value()?);
488 yield StreamPage {
489 records: drained,
490 bookmark,
491 };
492 } else {
493 agg_records.extend(drained);
494 }
495 if total_records >= max_messages {
496 stop = true;
497 }
498 }
499
500 if stop {
501 break;
502 }
503 }
504
505 if !per_transaction
511 && let Some(lsn) = state.last_committed
512 {
513 let bookmark = Some(Bookmark::from_u64(lsn).to_value()?);
514 yield StreamPage {
515 records: agg_records,
516 bookmark,
517 };
518 }
519
520 tracing::info!(
521 records = total_records,
522 batch_size,
523 "postgres-cdc: stream complete",
524 );
525 })
526 }
527}
528
529struct TupleRow {
532 values: Map<String, Value>,
533 unchanged_toast: Vec<String>,
534}
535
536#[derive(Default)]
538struct TxnState {
539 staged: Vec<Value>,
544 last_committed: Option<u64>,
546 in_progress_ts: i64,
549 in_progress_lsn: u64,
551 in_txn: bool,
553 max_staged_records: Option<usize>,
558}
559
560impl TxnState {
561 fn push_staged(&mut self, record: Value) -> Result<(), FaucetError> {
564 if let Some(max) = self.max_staged_records
565 && self.staged.len() >= max
566 {
567 return Err(FaucetError::Source(format!(
568 "postgres-cdc: in-progress transaction exceeded max_staged_records ({max}); \
569 aborting to avoid unbounded memory growth. Raise max_staged_records or \
570 reduce the size of the source transaction."
571 )));
572 }
573 self.staged.push(record);
574 Ok(())
575 }
576}
577
578fn handle_event(
579 event: ReplicationEvent,
580 registry: &mut RelationRegistry,
581 state: &mut TxnState,
582 out: &mut Vec<Value>,
583) -> Result<(), FaucetError> {
584 match event {
585 ReplicationEvent::Begin {
586 final_lsn,
587 commit_time_micros,
588 xid: _,
589 } => {
590 if state.in_txn {
591 return Err(FaucetError::Source(format!(
596 "postgres-cdc: BEGIN received while a previous transaction was still \
597 in progress ({} records staged) — replication stream desync",
598 state.staged.len()
599 )));
600 }
601 state.in_txn = true;
602 state.in_progress_lsn = final_lsn.as_u64();
603 state.in_progress_ts = commit_time_micros;
604 state.staged.clear();
605 }
606 ReplicationEvent::Commit {
607 lsn: _,
608 commit_time_micros: _,
609 end_lsn,
610 } => {
611 if !state.in_txn {
612 return Err(FaucetError::Source(
613 "postgres-cdc: COMMIT without BEGIN".into(),
614 ));
615 }
616 out.append(&mut state.staged);
634 state.last_committed = Some(end_lsn.as_u64());
635 state.in_txn = false;
636 }
637 ReplicationEvent::XLogData { data, .. } => {
638 let msg = decode_message(&data)?;
639 handle_pgoutput(msg, registry, state)?;
640 }
641 ReplicationEvent::Message { .. } => {
642 }
645 other => {
649 return Err(FaucetError::Source(format!(
650 "postgres-cdc: unhandled ReplicationEvent variant {other:?} — refusing to \
651 continue rather than risk silently dropping change data"
652 )));
653 }
654 }
655 Ok(())
656}
657
658fn handle_pgoutput(
659 msg: Message,
660 registry: &mut RelationRegistry,
661 state: &mut TxnState,
662) -> Result<(), FaucetError> {
663 match msg {
664 Message::Relation(r) => registry.insert(r),
665 Message::Origin | Message::Type => {} Message::Insert(i) => stage_insert(state, registry, i)?,
667 Message::Update(u) => stage_update(state, registry, u)?,
668 Message::Delete(d) => stage_delete(state, registry, d)?,
669 Message::Truncate(t) => stage_truncate(state, registry, t)?,
670 Message::Begin(_) | Message::Commit(_) => {
675 tracing::warn!(
676 "postgres-cdc: pgoutput Begin/Commit reached pgoutput decoder; \
677 pgwire-replication should have intercepted it"
678 );
679 }
680 }
681 Ok(())
682}
683
684fn stage_insert(
685 state: &mut TxnState,
686 registry: &RelationRegistry,
687 i: Insert,
688) -> Result<(), FaucetError> {
689 let rel = registry.get(i.relation_oid)?;
690 let after = tuple_to_object(rel, &i.new)?;
691 let r = record(rel, "insert", state, None, Some(after));
692 state.push_staged(r)
693}
694
695fn stage_update(
696 state: &mut TxnState,
697 registry: &RelationRegistry,
698 u: Update,
699) -> Result<(), FaucetError> {
700 let rel = registry.get(u.relation_oid)?;
701 let before = match &u.old {
702 Some(t) => Some(tuple_to_object(rel, t)?),
703 None => None,
704 };
705 let after = tuple_to_object(rel, &u.new)?;
706 let r = record(rel, "update", state, before, Some(after));
707 state.push_staged(r)
708}
709
710fn stage_delete(
711 state: &mut TxnState,
712 registry: &RelationRegistry,
713 d: Delete,
714) -> Result<(), FaucetError> {
715 let rel = registry.get(d.relation_oid)?;
716 let before = Some(tuple_to_object(rel, &d.old)?);
717 let r = record(rel, "delete", state, before, None);
718 state.push_staged(r)
719}
720
721fn stage_truncate(
722 state: &mut TxnState,
723 registry: &RelationRegistry,
724 t: Truncate,
725) -> Result<(), FaucetError> {
726 for oid in &t.relation_oids {
727 let rel = registry.get(*oid)?;
728 let r = record(rel, "truncate", state, None, None);
729 state.push_staged(r)?;
730 }
731 Ok(())
732}
733
734fn record(
735 rel: &Relation,
736 op: &str,
737 state: &TxnState,
738 before: Option<TupleRow>,
739 after: Option<TupleRow>,
740) -> Value {
741 fn to_value(row: TupleRow) -> Value {
742 let mut o = row.values;
743 if !row.unchanged_toast.is_empty() {
744 o.insert("__unchanged_toast__".into(), json!(row.unchanged_toast));
745 }
746 Value::Object(o)
747 }
748 let mut obj = Map::new();
749 obj.insert("op".into(), json!(op));
750 obj.insert("schema".into(), json!(rel.namespace));
751 obj.insert("table".into(), json!(rel.name));
752 obj.insert("lsn".into(), json!(format_lsn(state.in_progress_lsn)));
753 obj.insert(
754 "ts_ms".into(),
755 json!(postgres_clock_to_unix_ms(state.in_progress_ts)),
756 );
757 obj.insert("before".into(), before.map(to_value).unwrap_or(Value::Null));
758 obj.insert("after".into(), after.map(to_value).unwrap_or(Value::Null));
759 Value::Object(obj)
760}
761
762fn tuple_to_object(rel: &Relation, tup: &TupleData) -> Result<TupleRow, FaucetError> {
764 if tup.cells.len() != rel.columns.len() {
765 return Err(FaucetError::Source(format!(
766 "postgres-cdc: tuple has {} cells but relation {}.{} has {} columns",
767 tup.cells.len(),
768 rel.namespace,
769 rel.name,
770 rel.columns.len()
771 )));
772 }
773 let mut values = Map::with_capacity(rel.columns.len());
774 let mut unchanged_toast = Vec::new();
775 for (col, cell) in rel.columns.iter().zip(&tup.cells) {
776 match cell {
777 TupleCell::Null => {
778 values.insert(col.name.clone(), Value::Null);
779 }
780 TupleCell::UnchangedToast => {
781 unchanged_toast.push(col.name.clone());
782 }
783 TupleCell::Text(s) => {
784 values.insert(col.name.clone(), text_to_json(col.type_oid, s)?);
785 }
786 }
787 }
788 Ok(TupleRow {
789 values,
790 unchanged_toast,
791 })
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797 use crate::pgoutput::messages::{ColumnDesc, ReplicaIdentity};
798 use crate::replication::ReplicationEvent;
799 use pgwire_replication::Lsn;
800
801 fn rel_users() -> Relation {
802 Relation {
803 oid: 16384,
804 namespace: "public".into(),
805 name: "users".into(),
806 replica_identity: ReplicaIdentity::Default,
807 columns: vec![
808 ColumnDesc {
809 flags: 1,
810 name: "id".into(),
811 type_oid: 23,
812 type_modifier: -1,
813 },
814 ColumnDesc {
815 flags: 0,
816 name: "name".into(),
817 type_oid: 25,
818 type_modifier: -1,
819 },
820 ],
821 }
822 }
823
824 fn xlogdata(payload: Vec<u8>) -> ReplicationEvent {
825 ReplicationEvent::XLogData {
826 wal_start: Lsn::from_u64(0),
827 wal_end: Lsn::from_u64(0x16A_4F88),
828 server_time_micros: 0,
829 data: bytes::Bytes::from(payload),
830 }
831 }
832
833 fn insert_payload(relation_oid: u32, cells: &[(&str, &str)]) -> Vec<u8> {
834 let mut buf: Vec<u8> = Vec::new();
835 buf.push(b'I');
836 buf.extend_from_slice(&relation_oid.to_be_bytes());
837 buf.push(b'N');
838 buf.extend_from_slice(&(cells.len() as u16).to_be_bytes());
839 for (_, val) in cells {
840 text_cell(&mut buf, val);
841 }
842 buf
843 }
844
845 fn update_full_payload(
847 relation_oid: u32,
848 old_cells: &[(&str, &str)],
849 new_cells: &[(&str, &str)],
850 ) -> Vec<u8> {
851 let mut buf: Vec<u8> = Vec::new();
852 buf.push(b'U');
853 buf.extend_from_slice(&relation_oid.to_be_bytes());
854 buf.push(b'O');
855 buf.extend_from_slice(&(old_cells.len() as u16).to_be_bytes());
856 for (_, val) in old_cells {
857 text_cell(&mut buf, val);
858 }
859 buf.push(b'N');
860 buf.extend_from_slice(&(new_cells.len() as u16).to_be_bytes());
861 for (_, val) in new_cells {
862 text_cell(&mut buf, val);
863 }
864 buf
865 }
866
867 fn delete_full_payload(relation_oid: u32, old_cells: &[(&str, &str)]) -> Vec<u8> {
869 let mut buf: Vec<u8> = Vec::new();
870 buf.push(b'D');
871 buf.extend_from_slice(&relation_oid.to_be_bytes());
872 buf.push(b'O');
873 buf.extend_from_slice(&(old_cells.len() as u16).to_be_bytes());
874 for (_, val) in old_cells {
875 text_cell(&mut buf, val);
876 }
877 buf
878 }
879
880 fn truncate_payload(relation_oids: &[u32]) -> Vec<u8> {
882 let mut buf: Vec<u8> = Vec::new();
883 buf.push(b'T');
884 buf.extend_from_slice(&(relation_oids.len() as u32).to_be_bytes());
885 buf.push(0u8); for oid in relation_oids {
887 buf.extend_from_slice(&oid.to_be_bytes());
888 }
889 buf
890 }
891
892 fn text_cell(buf: &mut Vec<u8>, val: &str) {
893 buf.push(b't');
894 buf.extend_from_slice(&(val.len() as u32).to_be_bytes());
895 buf.extend_from_slice(val.as_bytes());
896 }
897
898 fn begin_event(final_lsn: u64) -> ReplicationEvent {
899 ReplicationEvent::Begin {
900 final_lsn: Lsn::from_u64(final_lsn),
901 xid: 1,
902 commit_time_micros: 0,
903 }
904 }
905
906 fn commit_event(lsn: u64) -> ReplicationEvent {
907 ReplicationEvent::Commit {
908 lsn: Lsn::from_u64(lsn),
909 end_lsn: Lsn::from_u64(lsn + 0x10),
910 commit_time_micros: 0,
911 }
912 }
913
914 #[test]
915 fn full_transaction_promotes_to_output_on_commit() {
916 let mut registry = RelationRegistry::new();
917 registry.insert(rel_users());
918 let mut state = TxnState::default();
919 let mut out = vec![];
920
921 handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
922 assert!(out.is_empty());
923
924 handle_event(
925 xlogdata(insert_payload(16384, &[("id", "1"), ("name", "alice")])),
926 &mut registry,
927 &mut state,
928 &mut out,
929 )
930 .unwrap();
931 assert!(out.is_empty(), "records stay staged until COMMIT");
932
933 handle_event(
934 commit_event(0x16A_4F88),
935 &mut registry,
936 &mut state,
937 &mut out,
938 )
939 .unwrap();
940
941 assert_eq!(out.len(), 1);
942 assert_eq!(out[0]["op"], "insert");
943 assert_eq!(out[0]["schema"], "public");
944 assert_eq!(out[0]["table"], "users");
945 assert_eq!(out[0]["lsn"], "0/16A4F88");
946 assert_eq!(out[0]["after"]["id"], 1);
947 assert_eq!(out[0]["after"]["name"], "alice");
948 assert_eq!(out[0]["before"], Value::Null);
949
950 assert_eq!(state.last_committed, Some(0x16A_4F88 + 0x10));
955 }
956
957 #[test]
958 fn staging_beyond_max_staged_records_aborts() {
959 let mut registry = RelationRegistry::new();
963 registry.insert(rel_users());
964 let mut state = TxnState {
965 max_staged_records: Some(2),
966 ..TxnState::default()
967 };
968 let mut out = vec![];
969
970 handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
971 for id in ["1", "2"] {
973 handle_event(
974 xlogdata(insert_payload(16384, &[("id", id), ("name", "x")])),
975 &mut registry,
976 &mut state,
977 &mut out,
978 )
979 .unwrap();
980 }
981 let err = handle_event(
983 xlogdata(insert_payload(16384, &[("id", "3"), ("name", "x")])),
984 &mut registry,
985 &mut state,
986 &mut out,
987 )
988 .unwrap_err();
989 assert!(
990 format!("{err}").contains("max_staged_records"),
991 "error must name the cap: {err}"
992 );
993 assert!(matches!(err, FaucetError::Source(_)));
994 }
995
996 #[test]
997 fn no_cap_allows_large_transactions() {
998 let mut registry = RelationRegistry::new();
1001 registry.insert(rel_users());
1002 let mut state = TxnState::default();
1003 let mut out = vec![];
1004
1005 handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
1006 for id in 0..50 {
1007 handle_event(
1008 xlogdata(insert_payload(
1009 16384,
1010 &[("id", &id.to_string()), ("name", "x")],
1011 )),
1012 &mut registry,
1013 &mut state,
1014 &mut out,
1015 )
1016 .unwrap();
1017 }
1018 handle_event(
1019 commit_event(0x16A_4F88),
1020 &mut registry,
1021 &mut state,
1022 &mut out,
1023 )
1024 .unwrap();
1025 assert_eq!(out.len(), 50);
1026 }
1027
1028 #[test]
1029 fn commit_without_begin_errors() {
1030 let mut registry = RelationRegistry::new();
1031 let mut state = TxnState::default();
1032 let mut out = vec![];
1033
1034 let err = handle_event(
1035 ReplicationEvent::Commit {
1036 lsn: Lsn::from_u64(1),
1037 end_lsn: Lsn::from_u64(2),
1038 commit_time_micros: 0,
1039 },
1040 &mut registry,
1041 &mut state,
1042 &mut out,
1043 )
1044 .unwrap_err();
1045 assert!(format!("{err}").contains("COMMIT without BEGIN"));
1046 }
1047
1048 #[test]
1049 fn double_begin_errors() {
1050 let mut registry = RelationRegistry::new();
1053 registry.insert(rel_users());
1054 let mut state = TxnState::default();
1055 let mut out = vec![];
1056
1057 handle_event(begin_event(0x100), &mut registry, &mut state, &mut out).unwrap();
1058 handle_event(
1059 xlogdata(insert_payload(16384, &[("id", "1"), ("name", "alice")])),
1060 &mut registry,
1061 &mut state,
1062 &mut out,
1063 )
1064 .unwrap();
1065
1066 let err =
1067 handle_event(begin_event(0x200), &mut registry, &mut state, &mut out).unwrap_err();
1068 assert!(format!("{err}").contains("desync"), "{err}");
1069 }
1070
1071 #[test]
1072 fn unknown_relation_in_insert_errors() {
1073 let mut registry = RelationRegistry::new();
1074 let mut state = TxnState::default();
1075 let mut out = vec![];
1076
1077 handle_event(begin_event(1), &mut registry, &mut state, &mut out).unwrap();
1078 let err = handle_event(
1080 xlogdata(insert_payload(99999, &[("id", "1"), ("name", "alice")])),
1081 &mut registry,
1082 &mut state,
1083 &mut out,
1084 )
1085 .unwrap_err();
1086 assert!(format!("{err}").contains("99999"));
1087 }
1088
1089 #[test]
1090 fn update_with_replica_identity_full_emits_before_and_after() {
1091 let mut registry = RelationRegistry::new();
1092 registry.insert(rel_users());
1093 let mut state = TxnState::default();
1094 let mut out = vec![];
1095
1096 handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
1097 handle_event(
1098 xlogdata(update_full_payload(
1099 16384,
1100 &[("id", "1"), ("name", "alice")],
1101 &[("id", "1"), ("name", "alice2")],
1102 )),
1103 &mut registry,
1104 &mut state,
1105 &mut out,
1106 )
1107 .unwrap();
1108 handle_event(
1109 commit_event(0x16A_4F88),
1110 &mut registry,
1111 &mut state,
1112 &mut out,
1113 )
1114 .unwrap();
1115
1116 assert_eq!(out.len(), 1);
1117 assert_eq!(out[0]["op"], "update");
1118 assert_eq!(out[0]["before"]["id"], 1);
1119 assert_eq!(out[0]["before"]["name"], "alice");
1120 assert_eq!(out[0]["after"]["name"], "alice2");
1121 }
1122
1123 #[test]
1124 fn delete_with_replica_identity_full_emits_before_only() {
1125 let mut registry = RelationRegistry::new();
1126 registry.insert(rel_users());
1127 let mut state = TxnState::default();
1128 let mut out = vec![];
1129
1130 handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
1131 handle_event(
1132 xlogdata(delete_full_payload(
1133 16384,
1134 &[("id", "1"), ("name", "alice")],
1135 )),
1136 &mut registry,
1137 &mut state,
1138 &mut out,
1139 )
1140 .unwrap();
1141 handle_event(
1142 commit_event(0x16A_4F88),
1143 &mut registry,
1144 &mut state,
1145 &mut out,
1146 )
1147 .unwrap();
1148
1149 assert_eq!(out.len(), 1);
1150 assert_eq!(out[0]["op"], "delete");
1151 assert_eq!(out[0]["before"]["id"], 1);
1152 assert_eq!(out[0]["before"]["name"], "alice");
1153 assert_eq!(out[0]["after"], Value::Null);
1154 }
1155
1156 #[test]
1157 fn truncate_emits_one_record_per_relation() {
1158 let mut registry = RelationRegistry::new();
1159 registry.insert(rel_users());
1160 let mut second = rel_users();
1162 second.oid = 16385;
1163 second.name = "orders".into();
1164 registry.insert(second);
1165
1166 let mut state = TxnState::default();
1167 let mut out = vec![];
1168
1169 handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
1170 handle_event(
1171 xlogdata(truncate_payload(&[16384, 16385])),
1172 &mut registry,
1173 &mut state,
1174 &mut out,
1175 )
1176 .unwrap();
1177 handle_event(
1178 commit_event(0x16A_4F88),
1179 &mut registry,
1180 &mut state,
1181 &mut out,
1182 )
1183 .unwrap();
1184
1185 assert_eq!(out.len(), 2);
1186 assert!(out.iter().all(|r| r["op"] == "truncate"));
1187 let tables: Vec<_> = out.iter().map(|r| r["table"].as_str().unwrap()).collect();
1188 assert!(tables.contains(&"users"));
1189 assert!(tables.contains(&"orders"));
1190 }
1191
1192 #[test]
1193 fn unchanged_toast_in_before_surfaces_via_metadata() {
1194 let mut registry = RelationRegistry::new();
1198 registry.insert(rel_users());
1199 let mut state = TxnState::default();
1200 let mut out = vec![];
1201
1202 handle_event(begin_event(0x16A_4F88), &mut registry, &mut state, &mut out).unwrap();
1203 let mut buf: Vec<u8> = Vec::new();
1205 buf.push(b'U');
1206 buf.extend_from_slice(&16384u32.to_be_bytes());
1207 buf.push(b'O');
1208 buf.extend_from_slice(&2u16.to_be_bytes());
1209 text_cell(&mut buf, "1");
1211 buf.push(b'u');
1213 buf.push(b'N');
1215 buf.extend_from_slice(&2u16.to_be_bytes());
1216 text_cell(&mut buf, "1");
1217 text_cell(&mut buf, "alice2");
1218 handle_event(xlogdata(buf), &mut registry, &mut state, &mut out).unwrap();
1219 handle_event(
1220 commit_event(0x16A_4F88),
1221 &mut registry,
1222 &mut state,
1223 &mut out,
1224 )
1225 .unwrap();
1226
1227 assert_eq!(out.len(), 1);
1228 assert_eq!(out[0]["before"]["__unchanged_toast__"], json!(["name"]));
1229 assert!(out[0]["before"].get("name").is_none());
1230 assert_eq!(out[0]["before"]["id"], 1);
1231 assert_eq!(out[0]["after"]["name"], "alice2");
1232 }
1233
1234 #[test]
1237 fn dataset_uri_strips_credentials() {
1238 let redacted = faucet_core::redact_uri_credentials("postgres://u:p@h:5432/db");
1239 let uri = format!("{}?publication={}", redacted, "my_pub");
1240 assert_eq!(uri, "postgres://h:5432/db?publication=my_pub");
1241 }
1242}