1use std::{
2 sync::{
3 Arc, Mutex,
4 atomic::{AtomicU64, Ordering},
5 },
6 time::{Duration, Instant},
7};
8
9use datum::{Channel, Source, SourceWithContext};
10use pgwire_replication::{ReplicationClient, ReplicationEvent};
11use tokio::sync::{mpsc, oneshot};
12
13use crate::{
14 CdcCheckpointHandle, CdcCheckpointStore, CdcError, CdcLag, CdcOffset, CdcResult, CdcStart,
15 ChangeEvent, FeedbackCommand, FeedbackCoordinator, FeedbackMode, PgLsn, PostgresCdcConfig,
16 ReconnectSettings, SlotLifecycle, SourceMetadata, TransactionMeta, admin,
17 config::SourceSettings,
18 pgoutput::{DecodedChange, PgOutputDecoder},
19 runtime,
20};
21
22pub struct CdcSource;
24
25impl CdcSource {
26 #[must_use]
27 pub fn postgres() -> PostgresCdcBuilder {
28 PostgresCdcBuilder::default()
29 }
30}
31
32#[derive(Clone)]
34pub struct PostgresCdcBuilder {
35 postgres: Option<PostgresCdcConfig>,
36 slot: Option<String>,
37 publication: Option<String>,
38 start: CdcStart,
39 feedback: FeedbackMode,
40 slot_lifecycle: SlotLifecycle,
41 buffer_capacity: usize,
42 replication_buffer_events: usize,
43 status_interval: Duration,
44 idle_wakeup_interval: Duration,
45 validate_publication: bool,
46 checkpoint_store: Option<Arc<dyn CdcCheckpointStore>>,
47 reconnect: Option<ReconnectSettings>,
48}
49
50impl Default for PostgresCdcBuilder {
51 fn default() -> Self {
52 Self {
53 postgres: None,
54 slot: None,
55 publication: None,
56 start: CdcStart::default(),
57 feedback: FeedbackMode::default(),
58 slot_lifecycle: SlotLifecycle::default(),
59 buffer_capacity: 8192,
60 replication_buffer_events: 8192,
61 status_interval: Duration::from_secs(1),
62 idle_wakeup_interval: Duration::from_secs(10),
63 validate_publication: true,
64 checkpoint_store: None,
65 reconnect: Some(ReconnectSettings::default()),
66 }
67 }
68}
69
70impl PostgresCdcBuilder {
71 #[must_use]
72 pub fn connect(mut self, config: PostgresCdcConfig) -> Self {
73 self.postgres = Some(config);
74 self
75 }
76
77 pub fn connect_url(self, url: impl AsRef<str>) -> CdcResult<Self> {
78 Ok(self.connect(PostgresCdcConfig::from_url(url)?))
79 }
80
81 #[must_use]
82 pub fn slot(mut self, slot: impl Into<String>) -> Self {
83 self.slot = Some(slot.into());
84 self
85 }
86
87 #[must_use]
88 pub fn publication(mut self, publication: impl Into<String>) -> Self {
89 self.publication = Some(publication.into());
90 self
91 }
92
93 #[must_use]
94 pub fn start_from(mut self, start: CdcStart) -> Self {
95 self.start = start;
96 self
97 }
98
99 #[must_use]
100 pub fn feedback(mut self, feedback: FeedbackMode) -> Self {
101 self.feedback = feedback;
102 self
103 }
104
105 #[must_use]
106 pub fn slot_lifecycle(mut self, lifecycle: SlotLifecycle) -> Self {
107 self.slot_lifecycle = lifecycle;
108 self
109 }
110
111 #[must_use]
112 pub fn buffer_capacity(mut self, capacity: usize) -> Self {
113 self.buffer_capacity = capacity;
114 self
115 }
116
117 #[must_use]
118 pub fn replication_buffer_events(mut self, capacity: usize) -> Self {
119 self.replication_buffer_events = capacity;
120 self
121 }
122
123 #[must_use]
124 pub fn status_interval(mut self, interval: Duration) -> Self {
125 self.status_interval = interval;
126 self
127 }
128
129 #[must_use]
130 pub fn idle_wakeup_interval(mut self, interval: Duration) -> Self {
131 self.idle_wakeup_interval = interval;
132 self
133 }
134
135 #[must_use]
136 pub fn validate_publication(mut self, enabled: bool) -> Self {
137 self.validate_publication = enabled;
138 self
139 }
140
141 #[must_use]
142 pub fn checkpoint_store<S>(mut self, store: S) -> Self
143 where
144 S: CdcCheckpointStore,
145 {
146 self.checkpoint_store = Some(Arc::new(store));
147 self
148 }
149
150 #[must_use]
151 pub fn checkpoint_store_arc(mut self, store: Arc<dyn CdcCheckpointStore>) -> Self {
152 self.checkpoint_store = Some(store);
153 self
154 }
155
156 #[must_use]
157 pub fn reconnect(mut self, settings: ReconnectSettings) -> Self {
158 self.reconnect = Some(settings);
159 self
160 }
161
162 #[must_use]
163 pub fn disable_reconnect(mut self) -> Self {
164 self.reconnect = None;
165 self
166 }
167
168 pub fn build(self) -> CdcResult<Source<ChangeEvent, CdcHandle>> {
169 let settings = Arc::new(self.finalize()?);
170 Ok(source_from_settings(settings))
171 }
172
173 pub fn build_with_context(
174 self,
175 ) -> CdcResult<SourceWithContext<ChangeEvent, CdcOffset, CdcHandle>> {
176 Ok(self
177 .build()?
178 .as_source_with_context(|event| event.lsn.clone()))
179 }
180
181 pub fn restart_factory(
182 self,
183 ) -> CdcResult<impl Fn() -> Source<ChangeEvent, CdcHandle> + Clone + Send + Sync + 'static>
184 {
185 let settings = Arc::new(self.finalize()?);
186 Ok(move || source_from_settings(Arc::clone(&settings)))
187 }
188
189 fn finalize(self) -> CdcResult<SourceSettings> {
190 if self.buffer_capacity == 0 {
191 return Err(CdcError::Config(
192 "CDC source buffer capacity must be greater than zero".into(),
193 ));
194 }
195 if self.replication_buffer_events == 0 {
196 return Err(CdcError::Config(
197 "replication event buffer capacity must be greater than zero".into(),
198 ));
199 }
200 let postgres = self
201 .postgres
202 .ok_or_else(|| CdcError::Config("PostgreSQL connection is required".into()))?;
203 if !matches!(postgres.tls, crate::CdcTlsConfig::Disable) {
204 return Err(CdcError::Config(
205 "TLS replication is not enabled in this workspace build because pgwire-replication 0.3.2 forces rustls/ring and conflicts with Datum's aws-lc-rs rustls provider; use sslmode=disable/prefer for this MVP"
206 .into(),
207 ));
208 }
209 let slot = self
210 .slot
211 .ok_or_else(|| CdcError::Config("replication slot is required".into()))?;
212 let publication = self
213 .publication
214 .ok_or_else(|| CdcError::Config("publication is required".into()))?;
215 Ok(SourceSettings {
216 source: SourceMetadata {
217 database: postgres.database.clone(),
218 slot: slot.clone(),
219 publication: publication.clone(),
220 },
221 postgres,
222 slot,
223 publication,
224 start: self.start,
225 feedback: self.feedback,
226 slot_lifecycle: self.slot_lifecycle,
227 buffer_capacity: self.buffer_capacity,
228 replication_buffer_events: self.replication_buffer_events,
229 status_interval: self.status_interval,
230 idle_wakeup_interval: self.idle_wakeup_interval,
231 validate_publication: self.validate_publication,
232 checkpoint_store: self.checkpoint_store,
233 reconnect: self.reconnect,
234 })
235 }
236}
237
238fn source_from_settings(settings: Arc<SourceSettings>) -> Source<ChangeEvent, CdcHandle> {
239 Source::channel(settings.buffer_capacity).map_materialized_value(move |channel| {
240 let (commands, command_rx) = mpsc::unbounded_channel();
241 let health = Arc::new(HealthState::default());
242 let checkpoint = CdcCheckpointHandle::new(
243 settings.slot.clone(),
244 settings.checkpoint_store.clone(),
245 commands.clone(),
246 );
247 let task_settings = Arc::clone(&settings);
248 let task_health = Arc::clone(&health);
249 runtime::spawn(async move {
250 run_carrier(task_settings, channel, command_rx, task_health).await;
251 });
252 CdcHandle {
253 settings: Arc::clone(&settings),
254 checkpoint,
255 commands,
256 health,
257 }
258 })
259}
260
261pub struct CdcHandle {
263 settings: Arc<SourceSettings>,
264 checkpoint: CdcCheckpointHandle,
265 commands: mpsc::UnboundedSender<FeedbackCommand>,
266 health: Arc<HealthState>,
267}
268
269impl CdcHandle {
270 #[must_use]
271 pub fn checkpoint_handle(&self) -> CdcCheckpointHandle {
272 self.checkpoint.clone()
273 }
274
275 #[must_use]
276 pub fn health(&self) -> CdcHealth {
277 self.health.snapshot()
278 }
279
280 pub fn stop(&self) -> CdcResult<()> {
281 self.commands
282 .send(FeedbackCommand::Stop)
283 .map_err(|_| CdcError::ChannelClosed)
284 }
285
286 pub async fn drop_slot(&self) -> CdcResult<()> {
287 self.drop_slot_inner(false).await
288 }
289
290 pub async fn force_drop_slot(&self) -> CdcResult<()> {
291 self.drop_slot_inner(true).await
292 }
293
294 pub async fn lag(&self) -> CdcResult<CdcLag> {
295 admin::sample_lag(&self.settings.postgres, &self.settings.slot).await
296 }
297
298 async fn drop_slot_inner(&self, force: bool) -> CdcResult<()> {
299 let (reply, wait) = oneshot::channel();
300 if self
301 .commands
302 .send(FeedbackCommand::DropSlot { force, reply })
303 .is_ok()
304 {
305 return wait
306 .await
307 .map_err(|err| CdcError::Runtime(format!("drop-slot reply lost: {err}")))?;
308 }
309 admin::drop_slot(
310 &self.settings.postgres,
311 &self.settings.slot,
312 self.settings.slot_lifecycle,
313 force,
314 )
315 .await
316 }
317}
318
319#[derive(Debug, Clone, Default, PartialEq, Eq)]
320pub struct CdcHealth {
321 pub running: bool,
322 pub last_error: Option<String>,
323 pub last_received_lsn: PgLsn,
324 pub latest_server_wal_end: PgLsn,
325 pub last_feedback_lsn: PgLsn,
326 pub emitted_events: u64,
327 pub reconnects: u64,
328}
329
330#[derive(Default)]
331struct HealthState {
332 snapshot: Mutex<CdcHealth>,
333 emitted_events: AtomicU64,
334 reconnects: AtomicU64,
335}
336
337impl HealthState {
338 fn snapshot(&self) -> CdcHealth {
339 let mut snapshot = self
340 .snapshot
341 .lock()
342 .expect("CDC health state poisoned")
343 .clone();
344 snapshot.emitted_events = self.emitted_events.load(Ordering::Acquire);
345 snapshot.reconnects = self.reconnects.load(Ordering::Acquire);
346 snapshot
347 }
348
349 fn set_running(&self, running: bool) {
350 self.update(|health| health.running = running);
351 }
352
353 fn set_error(&self, error: impl ToString) {
354 let error = error.to_string();
355 self.update(|health| health.last_error = Some(error));
356 }
357
358 fn clear_error(&self) {
359 self.update(|health| health.last_error = None);
360 }
361
362 fn record_received(&self, lsn: PgLsn) {
363 self.update(|health| health.last_received_lsn = lsn);
364 }
365
366 fn record_server_wal_end(&self, lsn: PgLsn) {
367 self.update(|health| health.latest_server_wal_end = lsn);
368 }
369
370 fn record_feedback(&self, lsn: PgLsn) {
371 self.update(|health| health.last_feedback_lsn = lsn);
372 }
373
374 fn record_emitted(&self) {
375 self.emitted_events.fetch_add(1, Ordering::AcqRel);
376 }
377
378 fn record_reconnect(&self) {
379 self.reconnects.fetch_add(1, Ordering::AcqRel);
380 }
381
382 fn update(&self, f: impl FnOnce(&mut CdcHealth)) {
383 let mut health = self.snapshot.lock().expect("CDC health state poisoned");
384 f(&mut health);
385 }
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389enum CarrierStop {
390 SourceClosed,
391 Stopped,
392 DroppedSlot,
393 ReplicationEnded,
394}
395
396async fn run_carrier(
397 settings: Arc<SourceSettings>,
398 channel: Channel<ChangeEvent>,
399 mut commands: mpsc::UnboundedReceiver<FeedbackCommand>,
400 health: Arc<HealthState>,
401) {
402 health.set_running(true);
403 let mut next_backoff = settings
404 .reconnect
405 .as_ref()
406 .map(|reconnect| reconnect.min_backoff);
407 let deadline = settings.start_deadline();
408 loop {
409 match run_once(&settings, &channel, &mut commands, &health).await {
410 Ok(CarrierStop::SourceClosed | CarrierStop::Stopped | CarrierStop::DroppedSlot) => {
411 break;
412 }
413 Ok(CarrierStop::ReplicationEnded) => {
414 if settings.reconnect.is_none() {
415 break;
416 }
417 }
418 Err(error) => {
419 health.set_error(&error);
420 if !should_reconnect(&error, settings.reconnect.as_ref(), deadline) {
421 break;
422 }
423 }
424 }
425
426 let Some(reconnect) = settings.reconnect.as_ref() else {
427 break;
428 };
429 let delay = next_backoff.unwrap_or(reconnect.min_backoff);
430 tokio::time::sleep(delay).await;
431 health.record_reconnect();
432 next_backoff = Some(delay.saturating_mul(2).min(reconnect.max_backoff));
433 }
434 channel.close();
435 health.set_running(false);
436}
437
438fn should_reconnect(
439 error: &CdcError,
440 reconnect: Option<&ReconnectSettings>,
441 deadline: Option<Instant>,
442) -> bool {
443 if reconnect.is_none() || deadline.is_some_and(|deadline| Instant::now() >= deadline) {
444 return false;
445 }
446 matches!(
447 error,
448 CdcError::Replication(_) | CdcError::Postgres(_) | CdcError::Io(_)
449 )
450}
451
452async fn run_once(
453 settings: &SourceSettings,
454 channel: &Channel<ChangeEvent>,
455 commands: &mut mpsc::UnboundedReceiver<FeedbackCommand>,
456 health: &HealthState,
457) -> CdcResult<CarrierStop> {
458 let start_lsn = admin::prepare_slot_and_start_lsn(settings).await?;
459 let mut feedback = FeedbackCoordinator::new(settings.slot.clone(), start_lsn);
460 let replication_config = settings.postgres.replication_config(
461 &settings.slot,
462 &settings.publication,
463 start_lsn,
464 settings.status_interval,
465 settings.idle_wakeup_interval,
466 settings.replication_buffer_events,
467 );
468 let mut client = ReplicationClient::connect(replication_config).await?;
469 health.clear_error();
470 let mut decoder = PgOutputDecoder::default();
471 let mut tx = None;
472
473 loop {
474 tokio::select! {
475 Some(command) = commands.recv() => {
476 if let Some(stop) = handle_command(command, settings, &mut client, &mut feedback, health).await? {
477 return Ok(stop);
478 }
479 }
480 event = client.recv() => {
481 let Some(event) = event? else {
482 return Ok(CarrierStop::ReplicationEnded);
483 };
484 let mut context = EventContext {
485 settings,
486 channel,
487 client: &mut client,
488 decoder: &mut decoder,
489 feedback: &mut feedback,
490 tx: &mut tx,
491 health,
492 };
493 if let Some(stop) = handle_event(event, &mut context).await? {
494 return Ok(stop);
495 }
496 }
497 }
498 }
499}
500
501async fn handle_command(
502 command: FeedbackCommand,
503 settings: &SourceSettings,
504 client: &mut ReplicationClient,
505 feedback: &mut FeedbackCoordinator,
506 health: &HealthState,
507) -> CdcResult<Option<CarrierStop>> {
508 match command {
509 FeedbackCommand::Checkpoint(offset) => {
510 match settings.feedback {
511 FeedbackMode::AfterCheckpoint => {
512 if let Some(lsn) = feedback.checkpoint(&offset)? {
513 client.update_applied_lsn(lsn.into());
514 health.record_feedback(lsn);
515 }
516 }
517 }
518 Ok(None)
519 }
520 FeedbackCommand::Stop => {
521 client.stop();
522 Ok(Some(CarrierStop::Stopped))
523 }
524 FeedbackCommand::DropSlot { force, reply } => {
525 client.stop();
526 let _ = client.shutdown().await;
527 let result = admin::drop_slot(
528 &settings.postgres,
529 &settings.slot,
530 settings.slot_lifecycle,
531 force,
532 )
533 .await;
534 let stop = if result.is_ok() {
535 CarrierStop::DroppedSlot
536 } else {
537 CarrierStop::Stopped
538 };
539 let _ = reply.send(result);
540 Ok(Some(stop))
541 }
542 }
543}
544
545struct EventContext<'a> {
546 settings: &'a SourceSettings,
547 channel: &'a Channel<ChangeEvent>,
548 client: &'a mut ReplicationClient,
549 decoder: &'a mut PgOutputDecoder,
550 feedback: &'a mut FeedbackCoordinator,
551 tx: &'a mut Option<PendingTransaction>,
552 health: &'a HealthState,
553}
554
555async fn handle_event(
556 event: ReplicationEvent,
557 context: &mut EventContext<'_>,
558) -> CdcResult<Option<CarrierStop>> {
559 match event {
560 ReplicationEvent::KeepAlive { wal_end, .. } => {
561 context.health.record_server_wal_end(wal_end.into());
562 }
563 ReplicationEvent::Begin {
564 final_lsn,
565 xid,
566 commit_time_micros,
567 } => {
568 *context.tx = Some(PendingTransaction {
569 xid,
570 final_lsn: final_lsn.into(),
571 commit_time_micros,
572 changes: Vec::new(),
573 });
574 }
575 ReplicationEvent::XLogData {
576 wal_start,
577 wal_end,
578 data,
579 ..
580 } => {
581 context.health.record_received(wal_end.into());
582 let changes = context.decoder.decode(&data)?;
583 if !changes.is_empty() {
584 let pending = context.tx.as_mut().ok_or_else(|| {
585 CdcError::Parse(format!(
586 "pgoutput row data at WAL {wal_start} arrived outside a transaction"
587 ))
588 })?;
589 pending.changes.extend(changes);
590 }
591 }
592 ReplicationEvent::Commit {
593 lsn,
594 end_lsn,
595 commit_time_micros,
596 } => {
597 let Some(mut pending) = context.tx.take() else {
598 return Ok(None);
599 };
600 let _begin_final_lsn = pending.final_lsn;
601 pending.commit_time_micros = commit_time_micros;
602 let commit_lsn = PgLsn::from(lsn);
603 let tx_end_lsn = PgLsn::from(end_lsn);
604 let event_count = u32::try_from(pending.changes.len()).map_err(|_| {
605 CdcError::Parse("transaction emitted more than u32::MAX events".into())
606 })?;
607 if event_count == 0 {
608 if let Some(lsn) = context.feedback.note_empty_tx(tx_end_lsn)? {
614 context.client.update_applied_lsn(lsn.into());
615 context.health.record_feedback(lsn);
616 }
617 return Ok(None);
618 }
619 context.feedback.register_tx(tx_end_lsn, event_count)?;
620 for (index, change) in pending.changes.into_iter().enumerate() {
621 let event = build_change_event(
622 context.settings,
623 change,
624 CommittedEventMeta {
625 xid: pending.xid,
626 commit_time_micros: pending.commit_time_micros,
627 commit_lsn,
628 tx_end_lsn,
629 event_index: u32::try_from(index)
630 .expect("event index bounded by event_count"),
631 event_count,
632 },
633 );
634 if context.channel.send(event).await.is_err() {
635 return Ok(Some(CarrierStop::SourceClosed));
636 }
637 context.health.record_emitted();
638 }
639 }
640 ReplicationEvent::Message { .. } | ReplicationEvent::StoppedAt { .. } => {}
641 }
642 Ok(None)
643}
644
645#[derive(Debug, Clone, Copy)]
646struct CommittedEventMeta {
647 xid: u32,
648 commit_time_micros: i64,
649 commit_lsn: PgLsn,
650 tx_end_lsn: PgLsn,
651 event_index: u32,
652 event_count: u32,
653}
654
655fn build_change_event(
656 settings: &SourceSettings,
657 change: DecodedChange,
658 meta: CommittedEventMeta,
659) -> ChangeEvent {
660 let offset = CdcOffset {
661 slot: settings.slot.clone(),
662 tx_end_lsn: meta.tx_end_lsn,
663 commit_lsn: meta.commit_lsn,
664 xid: meta.xid,
665 event_index: meta.event_index,
666 event_count: meta.event_count,
667 };
668 let tx = TransactionMeta {
669 xid: meta.xid,
670 commit_time_micros: meta.commit_time_micros,
671 event_index: meta.event_index,
672 event_count: meta.event_count,
673 };
674 ChangeEvent {
675 source: settings.source.clone(),
676 schema: change.relation.schema.clone(),
677 table: change.relation.table.clone(),
678 op: change.op,
679 before: change.before,
680 after: change.after,
681 truncate: change.truncate,
682 lsn: offset,
683 tx,
684 relation: change.relation,
685 }
686}
687
688#[derive(Debug)]
689struct PendingTransaction {
690 xid: u32,
691 final_lsn: PgLsn,
692 commit_time_micros: i64,
693 changes: Vec<DecodedChange>,
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699 use crate::MemoryCheckpointStore;
700
701 #[test]
702 fn builder_requires_connection_slot_and_publication() {
703 let err = match CdcSource::postgres().build() {
704 Ok(_) => panic!("builder unexpectedly succeeded"),
705 Err(err) => err,
706 };
707 assert!(matches!(err, CdcError::Config(_)));
708 }
709
710 #[test]
711 fn builder_creates_context_source() {
712 let config = PostgresCdcConfig::from_url("postgresql://datum@127.0.0.1:5432/db").unwrap();
713 let source = CdcSource::postgres()
714 .connect(config)
715 .slot("slot")
716 .publication("pub")
717 .checkpoint_store(MemoryCheckpointStore::new())
718 .build_with_context();
719 assert!(source.is_ok());
720 }
721}