1#![allow(unexpected_cfgs)]
16
17pub mod config;
173pub mod connection;
174pub mod decoder;
175pub mod descriptor;
176pub mod protocol;
177pub mod scram;
178pub mod stream;
179pub mod types;
180
181pub use config::{PostgresSourceConfig, SslMode, TableKeyConfig};
182
183use anyhow::{anyhow, Context, Result};
184use async_trait::async_trait;
185use drasi_lib::schema::{
186 normalize_table_label, NodeSchema, PropertySchema, PropertyType, SourceSchema,
187};
188use log::{debug, error, info};
189use postgres_native_tls::MakeTlsConnector;
190use std::collections::HashMap;
191use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
192use std::sync::Arc;
193use tokio::sync::{oneshot, Mutex};
194
195use drasi_lib::channels::{ComponentStatus, DispatchMode, *};
196use drasi_lib::sources::base::{SourceBase, SourceBaseParams};
197use drasi_lib::{Source, SourceError};
198use tracing::Instrument;
199
200pub(crate) struct ReplayState {
207 pub(crate) read_lsn: AtomicU64,
208 pub(crate) flush_fence_lsn: AtomicU64,
215 pub(crate) fence_set_epoch_secs: AtomicU64,
218}
219
220const FENCE_TIMEOUT_SECS: u64 = 60;
227
228impl Default for ReplayState {
229 fn default() -> Self {
230 Self {
231 read_lsn: AtomicU64::new(0),
232 flush_fence_lsn: AtomicU64::new(u64::MAX),
233 fence_set_epoch_secs: AtomicU64::new(0),
234 }
235 }
236}
237
238impl ReplayState {
239 fn current_read_lsn(&self) -> u64 {
240 self.read_lsn.load(Ordering::Acquire)
241 }
242
243 fn set_flush_fence(&self, lsn: u64) {
246 use std::time::{SystemTime, UNIX_EPOCH};
247 let now_secs = SystemTime::now()
248 .duration_since(UNIX_EPOCH)
249 .unwrap_or_default()
250 .as_secs();
251 self.flush_fence_lsn.store(lsn, Ordering::Release);
252 self.fence_set_epoch_secs.store(now_secs, Ordering::Release);
253 }
254
255 fn clear_flush_fence(&self) {
257 self.flush_fence_lsn.store(u64::MAX, Ordering::Release);
258 }
259
260 fn effective_flush_fence(&self) -> u64 {
263 let fence = self.flush_fence_lsn.load(Ordering::Acquire);
264 if fence == u64::MAX {
265 return u64::MAX;
266 }
267 use std::time::{SystemTime, UNIX_EPOCH};
268 let now_secs = SystemTime::now()
269 .duration_since(UNIX_EPOCH)
270 .unwrap_or_default()
271 .as_secs();
272 let set_secs = self.fence_set_epoch_secs.load(Ordering::Acquire);
273 if now_secs.saturating_sub(set_secs) > FENCE_TIMEOUT_SECS {
274 self.flush_fence_lsn.store(u64::MAX, Ordering::Release);
276 u64::MAX
277 } else {
278 fence
279 }
280 }
281}
282
283pub struct PostgresReplicationSource {
294 base: SourceBase,
296 config: PostgresSourceConfig,
298 cached_schema: Arc<std::sync::RwLock<Option<SourceSchema>>>,
300 replay_state: Arc<ReplayState>,
302 subscribe_lock: Mutex<()>,
304}
305
306fn postgres_type_to_property_type(data_type: &str) -> Option<PropertyType> {
307 match data_type {
308 "smallint" | "integer" | "bigint" => Some(PropertyType::Integer),
309 "real" | "double precision" | "numeric" | "decimal" => Some(PropertyType::Float),
310 "boolean" => Some(PropertyType::Boolean),
311 "timestamp without time zone"
312 | "timestamp with time zone"
313 | "date"
314 | "time without time zone"
315 | "time with time zone" => Some(PropertyType::Timestamp),
316 "json" | "jsonb" => Some(PropertyType::Json),
317 "character" | "character varying" | "text" | "uuid" | "bytea" => Some(PropertyType::String),
318 _ => None,
319 }
320}
321
322async fn introspect_postgres_schema(config: &PostgresSourceConfig) -> Result<Option<SourceSchema>> {
323 if config.tables.is_empty() {
324 return Ok(None);
325 }
326
327 let mut pg_config = tokio_postgres::Config::new();
328 pg_config.host(&config.host);
329 pg_config.port(config.port);
330 pg_config.dbname(&config.database);
331 pg_config.user(&config.user);
332 if !config.password.is_empty() {
333 pg_config.password(&config.password);
334 }
335
336 let client = match config.ssl_mode {
337 SslMode::Require => {
338 pg_config.ssl_mode(tokio_postgres::config::SslMode::Require);
339 let tls_connector = native_tls::TlsConnector::builder()
340 .danger_accept_invalid_hostnames(false)
341 .danger_accept_invalid_certs(false)
342 .build()
343 .map_err(|e| anyhow!("Failed to create TLS connector: {e}"))?;
344 let connector = MakeTlsConnector::new(tls_connector);
345
346 debug!("Schema introspection: connecting with SSL (require)");
347 let (client, connection) = pg_config.connect(connector).await?;
348 tokio::spawn(async move {
349 if let Err(e) = connection.await {
350 log::warn!("PostgreSQL schema introspection connection closed: {e}");
351 }
352 });
353 client
354 }
355 SslMode::Prefer => {
356 let tls_connector = native_tls::TlsConnector::builder()
358 .danger_accept_invalid_hostnames(false)
359 .danger_accept_invalid_certs(false)
360 .build()
361 .map_err(|e| anyhow!("Failed to create TLS connector: {e}"))?;
362 let connector = MakeTlsConnector::new(tls_connector);
363
364 pg_config.ssl_mode(tokio_postgres::config::SslMode::Prefer);
365 debug!("Schema introspection: connecting with SSL (prefer)");
366 let (client, connection) = pg_config.connect(connector).await?;
367 tokio::spawn(async move {
368 if let Err(e) = connection.await {
369 log::warn!("PostgreSQL schema introspection connection closed: {e}");
370 }
371 });
372 client
373 }
374 SslMode::Disable => {
375 debug!("Schema introspection: connecting without SSL");
376 let (client, connection) = pg_config.connect(tokio_postgres::NoTls).await?;
377 tokio::spawn(async move {
378 if let Err(e) = connection.await {
379 log::warn!("PostgreSQL schema introspection connection closed: {e}");
380 }
381 });
382 client
383 }
384 };
385
386 let mut nodes = Vec::new();
387
388 for table in &config.tables {
389 let (schema_name, table_name) = table
390 .split_once('.')
391 .map(|(schema, name)| (schema.to_string(), name.to_string()))
392 .unwrap_or_else(|| ("public".to_string(), table.to_string()));
393
394 let rows = client
395 .query(
396 "SELECT column_name, data_type \
397 FROM information_schema.columns \
398 WHERE table_schema = $1 AND table_name = $2 \
399 ORDER BY ordinal_position",
400 &[&schema_name, &table_name],
401 )
402 .await?;
403
404 let properties = rows
405 .into_iter()
406 .map(|row| PropertySchema {
407 name: row.get::<_, String>(0),
408 data_type: postgres_type_to_property_type(&row.get::<_, String>(1)),
409 description: None,
410 })
411 .collect();
412
413 nodes.push(NodeSchema {
414 label: normalize_table_label(&table_name),
415 properties,
416 });
417 }
418
419 Ok(Some(SourceSchema {
420 nodes,
421 relations: Vec::new(),
422 }))
423}
424
425impl PostgresReplicationSource {
426 pub fn builder(id: impl Into<String>) -> PostgresSourceBuilder {
445 PostgresSourceBuilder::new(id)
446 }
447
448 pub fn new(id: impl Into<String>, config: PostgresSourceConfig) -> Result<Self> {
490 let id = id.into();
491 let params = SourceBaseParams::new(id);
492 Ok(Self {
493 base: SourceBase::new(params)?,
494 config,
495 cached_schema: Arc::new(std::sync::RwLock::new(None)),
496 replay_state: Arc::new(ReplayState::default()),
497 subscribe_lock: Mutex::new(()),
498 })
499 }
500
501 pub fn with_dispatch(
506 id: impl Into<String>,
507 config: PostgresSourceConfig,
508 dispatch_mode: Option<DispatchMode>,
509 dispatch_buffer_capacity: Option<usize>,
510 ) -> Result<Self> {
511 let id = id.into();
512 let mut params = SourceBaseParams::new(id);
513 if let Some(mode) = dispatch_mode {
514 params = params.with_dispatch_mode(mode);
515 }
516 if let Some(capacity) = dispatch_buffer_capacity {
517 params = params.with_dispatch_buffer_capacity(capacity);
518 }
519 Ok(Self {
520 base: SourceBase::new(params)?,
521 config,
522 cached_schema: Arc::new(std::sync::RwLock::new(None)),
523 replay_state: Arc::new(ReplayState::default()),
524 subscribe_lock: Mutex::new(()),
525 })
526 }
527}
528
529impl PostgresReplicationSource {
530 async fn spawn_replication_task(
535 &self,
536 start_lsn: Option<u64>,
537 wait_for_bootstrap_boundary: bool,
538 ) -> Result<()> {
539 let config = self.config.clone();
540 let source_id = self.base.id.clone();
541 let reporter = self.base.status_handle();
542 let base = self.base.clone_shared();
543 let replay_state = self.replay_state.clone();
544 let (startup_tx, startup_rx) = oneshot::channel::<std::result::Result<(), String>>();
551
552 let instance_id = self
553 .base
554 .context()
555 .await
556 .map(|c| c.instance_id)
557 .unwrap_or_default();
558
559 let source_id_for_span = source_id.clone();
560 let span = tracing::info_span!(
561 "postgres_replication_task",
562 instance_id = %instance_id,
563 component_id = %source_id_for_span,
564 component_type = "source",
565 start_lsn = ?start_lsn
566 );
567
568 let task = tokio::spawn(
569 async move {
570 info!("Starting replication for source {source_id}");
571 let mut startup_tx = Some(startup_tx);
572
573 let effective_start_lsn = if wait_for_bootstrap_boundary {
574 if let Some(tx) = startup_tx.take() {
577 let _ = tx.send(Ok(()));
578 }
579
580 base.wait_for_subscribers().await;
586
587 if base.take_pending_initial_bootstrap() {
588 info!(
589 "PostgreSQL source '{source_id}' waiting for bootstrap snapshot boundary"
590 );
591 match base.wait_for_bootstrap_boundary().await {
592 Some(boundary) => match connection::position_bytes_to_lsn(&boundary)
593 .context("invalid PostgreSQL bootstrap boundary position")
594 {
595 Ok(lsn) => {
596 info!(
597 "PostgreSQL source '{source_id}' starting CDC from bootstrap boundary LSN {lsn:x}"
598 );
599 Some(lsn)
600 }
601 Err(e) => {
602 error!("Replication task failed for {source_id}: {e}");
603 reporter
604 .set_status(
605 ComponentStatus::Error,
606 Some(format!("Replication failed: {e}")),
607 )
608 .await;
609 return;
610 }
611 },
612 None => {
613 info!(
614 "PostgreSQL source '{source_id}' stopped before bootstrap boundary was published"
615 );
616 return;
617 }
618 }
619 } else {
620 info!(
621 "PostgreSQL source '{source_id}' starting CDC from current position (no initial bootstrap requested)"
622 );
623 start_lsn
624 }
625 } else {
626 start_lsn
627 };
628
629 let mut stream = stream::ReplicationStream::new(
630 config,
631 source_id.clone(),
632 reporter.clone(),
633 base,
634 replay_state,
635 effective_start_lsn,
636 );
637
638 if let Err(e) = stream.run(startup_tx).await {
639 error!("Replication task failed for {source_id}: {e}");
640 reporter
641 .set_status(
642 ComponentStatus::Error,
643 Some(format!("Replication failed: {e}")),
644 )
645 .await;
646 }
647 }
648 .instrument(span),
649 );
650
651 *self.base.task_handle.write().await = Some(task);
652
653 match startup_rx.await {
654 Ok(Ok(())) => Ok(()),
655 Ok(Err(message)) => {
656 let _ = self.base.task_handle.write().await.take();
657 Err(anyhow!(
658 "Failed to establish PostgreSQL replication: {message}"
659 ))
660 }
661 Err(_) => {
662 let _ = self.base.task_handle.write().await.take();
663 Err(anyhow!(
664 "PostgreSQL replication task exited before confirming startup"
665 ))
666 }
667 }
668 }
669
670 async fn abort_replication_task(&self) {
671 if let Some(task) = self.base.task_handle.write().await.take() {
672 task.abort();
673 let _ = task.await;
674 }
675 }
676
677 async fn pause_replication_for_restart(&self, start_lsn: u64) {
678 info!(
679 "Pausing PostgreSQL source '{}' before replay from requested LSN {:x}",
680 self.base.id, start_lsn
681 );
682
683 self.base
684 .set_status(
685 ComponentStatus::Starting,
686 Some(format!(
687 "Rewinding PostgreSQL replication to LSN {start_lsn:x}"
688 )),
689 )
690 .await;
691
692 self.abort_replication_task().await;
693
694 self.base.clear_sequence_position_map().await;
700
701 self.replay_state.set_flush_fence(start_lsn);
706 }
707
708 async fn resume_replication_from(&self, start_lsn: u64) -> Result<()> {
709 self.spawn_replication_task(Some(start_lsn), false).await?;
710
711 self.base
712 .set_status(
713 ComponentStatus::Running,
714 Some(format!(
715 "PostgreSQL replication resumed from LSN {start_lsn:x}"
716 )),
717 )
718 .await;
719
720 Ok(())
721 }
722
723 async fn restart_replication_from(&self, start_lsn: u64) -> Result<()> {
724 info!(
725 "Restarting PostgreSQL source '{}' from requested LSN {:x}",
726 self.base.id, start_lsn
727 );
728
729 self.pause_replication_for_restart(start_lsn).await;
730 self.resume_replication_from(start_lsn).await
731 }
732
733 async fn get_earliest_available_lsn(&self) -> Result<u64> {
736 let mut conn = connection::ReplicationConnection::connect(
737 &self.config.host,
738 self.config.port,
739 &self.config.database,
740 &self.config.user,
741 &self.config.password,
742 )
743 .await?;
744
745 let _ = conn.identify_system().await?;
746 let slot_info = conn
747 .get_replication_slot_info(&self.config.slot_name)
748 .await?;
749 let _ = conn.close().await;
750
751 let lsn_str = slot_info
754 .restart_lsn
755 .as_deref()
756 .unwrap_or(&slot_info.consistent_point);
757
758 if lsn_str.is_empty() || lsn_str == "0/0" {
759 Ok(0)
760 } else {
761 connection::parse_lsn(lsn_str)
762 }
763 }
764}
765
766#[async_trait]
767impl Source for PostgresReplicationSource {
768 fn id(&self) -> &str {
769 &self.base.id
770 }
771
772 fn type_name(&self) -> &str {
773 "postgres"
774 }
775
776 fn properties(&self) -> HashMap<String, serde_json::Value> {
777 use crate::descriptor::PostgresSourceConfigDto;
778
779 self.base
780 .properties_or_serialize(&PostgresSourceConfigDto::from(&self.config))
781 }
782
783 fn auto_start(&self) -> bool {
784 self.base.get_auto_start()
785 }
786
787 fn describe_schema(&self) -> Option<SourceSchema> {
788 self.cached_schema
789 .read()
790 .ok()
791 .and_then(|schema| schema.clone())
792 .or_else(|| {
793 if self.config.tables.is_empty() {
794 None
795 } else {
796 Some(SourceSchema {
797 nodes: self
798 .config
799 .tables
800 .iter()
801 .map(|table| NodeSchema::new(normalize_table_label(table)))
802 .collect(),
803 relations: Vec::new(),
804 })
805 }
806 })
807 }
808
809 async fn start(&self) -> Result<()> {
810 if self.base.get_status().await == ComponentStatus::Running {
811 return Ok(());
812 }
813
814 self.base.set_status(ComponentStatus::Starting, None).await;
815 self.base.reset_bootstrap_boundary();
816 info!("Starting PostgreSQL replication source: {}", self.base.id);
817
818 match introspect_postgres_schema(&self.config).await {
819 Ok(Some(schema)) => {
820 if let Ok(mut cached) = self.cached_schema.write() {
821 *cached = Some(schema);
822 }
823 }
824 Ok(None) => {}
825 Err(e) => {
826 log::warn!(
827 "Failed to introspect PostgreSQL schema for '{}': {e}",
828 self.base.id
829 );
830 }
831 }
832
833 let wait_for_bootstrap_boundary = self.base.has_bootstrap_provider();
834 self.spawn_replication_task(None, wait_for_bootstrap_boundary)
835 .await?;
836 self.base
837 .set_status(
838 ComponentStatus::Running,
839 Some("PostgreSQL replication started".to_string()),
840 )
841 .await;
842
843 Ok(())
844 }
845
846 async fn stop(&self) -> Result<()> {
847 if self.base.get_status().await != ComponentStatus::Running {
848 return Ok(());
849 }
850
851 info!("Stopping PostgreSQL replication source: {}", self.base.id);
852
853 self.base.set_status(ComponentStatus::Stopping, None).await;
854
855 self.abort_replication_task().await;
856
857 if let Ok(mut cached) = self.cached_schema.write() {
859 *cached = None;
860 }
861
862 self.base
863 .set_status(
864 ComponentStatus::Stopped,
865 Some("PostgreSQL replication stopped".to_string()),
866 )
867 .await;
868
869 Ok(())
870 }
871
872 async fn status(&self) -> ComponentStatus {
873 self.base.get_status().await
874 }
875
876 async fn subscribe(
877 &self,
878 settings: drasi_lib::config::SourceSubscriptionSettings,
879 ) -> Result<SubscriptionResponse> {
880 let _guard = self.subscribe_lock.lock().await;
883
884 let mut restart_from = None;
885 let mut pause_before_subscribe = false;
886
887 if let Some(ref resume_bytes) = settings.resume_from {
888 let resume_lsn = connection::position_bytes_to_lsn(resume_bytes)?;
889
890 let earliest_available = self.get_earliest_available_lsn().await?;
891 if resume_lsn < earliest_available {
892 return Err(SourceError::PositionUnavailable {
893 source_id: self.base.id.clone(),
894 requested: resume_bytes.clone(),
895 earliest_available: Some(connection::lsn_to_position_bytes(earliest_available)),
896 }
897 .into());
898 }
899
900 let read_lsn = self.replay_state.current_read_lsn();
901 let is_running = self.base.get_status().await == ComponentStatus::Running;
902
903 if !is_running || read_lsn == 0 || resume_lsn < read_lsn {
904 restart_from = Some(resume_lsn);
905 pause_before_subscribe = is_running;
906 }
907 }
908
909 if let Some(start_lsn) = restart_from.filter(|_| pause_before_subscribe) {
910 self.pause_replication_for_restart(start_lsn).await;
914 }
915
916 let response = match self
917 .base
918 .subscribe_with_bootstrap(&settings, "PostgreSQL")
919 .await
920 {
921 Ok(response) => response,
922 Err(err) => {
923 if pause_before_subscribe {
924 self.base
925 .set_status(
926 ComponentStatus::Error,
927 Some(format!("Failed to register replay subscription: {err}")),
928 )
929 .await;
930 }
931 return Err(err);
932 }
933 };
934
935 if let Some(start_lsn) = restart_from {
936 if pause_before_subscribe {
937 self.resume_replication_from(start_lsn).await?;
938 } else {
939 self.restart_replication_from(start_lsn).await?;
940 }
941 }
942
943 Ok(response)
944 }
945
946 fn as_any(&self) -> &dyn std::any::Any {
947 self
948 }
949
950 async fn initialize(&self, context: drasi_lib::context::SourceRuntimeContext) {
951 self.base.initialize(context).await;
952 self.base
954 .set_position_comparator(drasi_lib::sources::ByteLexPositionComparator)
955 .await;
956 }
957
958 async fn remove_position_handle(&self, query_id: &str) {
959 self.base.remove_position_handle(query_id).await;
960 }
961
962 async fn on_subscriptions_complete(&self) {
963 self.replay_state.clear_flush_fence();
966 }
967
968 async fn set_bootstrap_provider(
969 &self,
970 provider: Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>,
971 ) {
972 self.base.set_bootstrap_provider(provider).await;
973 }
974}
975
976pub struct PostgresSourceBuilder {
999 id: String,
1000 host: String,
1001 port: u16,
1002 database: String,
1003 user: String,
1004 password: String,
1005 tables: Vec<String>,
1006 slot_name: String,
1007 publication_name: String,
1008 ssl_mode: SslMode,
1009 table_keys: Vec<TableKeyConfig>,
1010 dispatch_mode: Option<DispatchMode>,
1011 dispatch_buffer_capacity: Option<usize>,
1012 bootstrap_provider: Option<Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>>,
1013 auto_start: bool,
1014}
1015
1016impl PostgresSourceBuilder {
1017 pub fn new(id: impl Into<String>) -> Self {
1019 Self {
1020 id: id.into(),
1021 host: "localhost".to_string(),
1022 port: 5432,
1023 database: String::new(),
1024 user: String::new(),
1025 password: String::new(),
1026 tables: Vec::new(),
1027 slot_name: "drasi_slot".to_string(),
1028 publication_name: "drasi_publication".to_string(),
1029 ssl_mode: SslMode::default(),
1030 table_keys: Vec::new(),
1031 dispatch_mode: None,
1032 dispatch_buffer_capacity: None,
1033 bootstrap_provider: None,
1034 auto_start: true,
1035 }
1036 }
1037
1038 pub fn with_host(mut self, host: impl Into<String>) -> Self {
1040 self.host = host.into();
1041 self
1042 }
1043
1044 pub fn with_port(mut self, port: u16) -> Self {
1046 self.port = port;
1047 self
1048 }
1049
1050 pub fn with_database(mut self, database: impl Into<String>) -> Self {
1052 self.database = database.into();
1053 self
1054 }
1055
1056 pub fn with_user(mut self, user: impl Into<String>) -> Self {
1058 self.user = user.into();
1059 self
1060 }
1061
1062 pub fn with_password(mut self, password: impl Into<String>) -> Self {
1064 self.password = password.into();
1065 self
1066 }
1067
1068 pub fn with_tables(mut self, tables: Vec<String>) -> Self {
1070 self.tables = tables;
1071 self
1072 }
1073
1074 pub fn add_table(mut self, table: impl Into<String>) -> Self {
1076 self.tables.push(table.into());
1077 self
1078 }
1079
1080 pub fn with_slot_name(mut self, slot_name: impl Into<String>) -> Self {
1082 self.slot_name = slot_name.into();
1083 self
1084 }
1085
1086 pub fn with_publication_name(mut self, publication_name: impl Into<String>) -> Self {
1088 self.publication_name = publication_name.into();
1089 self
1090 }
1091
1092 pub fn with_ssl_mode(mut self, ssl_mode: SslMode) -> Self {
1094 self.ssl_mode = ssl_mode;
1095 self
1096 }
1097
1098 pub fn with_table_keys(mut self, table_keys: Vec<TableKeyConfig>) -> Self {
1100 self.table_keys = table_keys;
1101 self
1102 }
1103
1104 pub fn add_table_key(mut self, table_key: TableKeyConfig) -> Self {
1106 self.table_keys.push(table_key);
1107 self
1108 }
1109
1110 pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
1112 self.dispatch_mode = Some(mode);
1113 self
1114 }
1115
1116 pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
1118 self.dispatch_buffer_capacity = Some(capacity);
1119 self
1120 }
1121
1122 pub fn with_bootstrap_provider(
1124 mut self,
1125 provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
1126 ) -> Self {
1127 self.bootstrap_provider = Some(Box::new(provider));
1128 self
1129 }
1130
1131 pub fn with_auto_start(mut self, auto_start: bool) -> Self {
1136 self.auto_start = auto_start;
1137 self
1138 }
1139
1140 pub fn with_config(mut self, config: PostgresSourceConfig) -> Self {
1142 self.host = config.host;
1143 self.port = config.port;
1144 self.database = config.database;
1145 self.user = config.user;
1146 self.password = config.password;
1147 self.tables = config.tables;
1148 self.slot_name = config.slot_name;
1149 self.publication_name = config.publication_name;
1150 self.ssl_mode = config.ssl_mode;
1151 self.table_keys = config.table_keys;
1152 self
1153 }
1154
1155 pub fn build(self) -> Result<PostgresReplicationSource> {
1161 let config = PostgresSourceConfig {
1162 host: self.host,
1163 port: self.port,
1164 database: self.database,
1165 user: self.user,
1166 password: self.password,
1167 tables: self.tables,
1168 slot_name: self.slot_name,
1169 publication_name: self.publication_name,
1170 ssl_mode: self.ssl_mode,
1171 table_keys: self.table_keys,
1172 };
1173
1174 let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
1175 if let Some(mode) = self.dispatch_mode {
1176 params = params.with_dispatch_mode(mode);
1177 }
1178 if let Some(capacity) = self.dispatch_buffer_capacity {
1179 params = params.with_dispatch_buffer_capacity(capacity);
1180 }
1181 if let Some(provider) = self.bootstrap_provider {
1182 params = params.with_bootstrap_provider(provider);
1183 }
1184
1185 Ok(PostgresReplicationSource {
1186 base: SourceBase::new(params)?,
1187 config,
1188 cached_schema: Arc::new(std::sync::RwLock::new(None)),
1189 replay_state: Arc::new(ReplayState::default()),
1190 subscribe_lock: Mutex::new(()),
1191 })
1192 }
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197 use super::*;
1198
1199 mod construction {
1200 use super::*;
1201
1202 #[test]
1203 fn test_builder_with_valid_config() {
1204 let source = PostgresSourceBuilder::new("test-source")
1205 .with_database("testdb")
1206 .with_user("testuser")
1207 .build();
1208 assert!(source.is_ok());
1209 }
1210
1211 #[test]
1212 fn test_builder_with_custom_config() {
1213 let source = PostgresSourceBuilder::new("pg-source")
1214 .with_host("192.168.1.100")
1215 .with_port(5433)
1216 .with_database("production")
1217 .with_user("admin")
1218 .with_password("secret")
1219 .build()
1220 .unwrap();
1221 assert_eq!(source.id(), "pg-source");
1222 }
1223
1224 #[test]
1225 fn test_with_dispatch_creates_source() {
1226 let config = PostgresSourceConfig {
1227 host: "localhost".to_string(),
1228 port: 5432,
1229 database: "testdb".to_string(),
1230 user: "testuser".to_string(),
1231 password: String::new(),
1232 tables: Vec::new(),
1233 slot_name: "drasi_slot".to_string(),
1234 publication_name: "drasi_publication".to_string(),
1235 ssl_mode: SslMode::default(),
1236 table_keys: Vec::new(),
1237 };
1238 let source = PostgresReplicationSource::with_dispatch(
1239 "dispatch-source",
1240 config,
1241 Some(DispatchMode::Channel),
1242 Some(2000),
1243 );
1244 assert!(source.is_ok());
1245 assert_eq!(source.unwrap().id(), "dispatch-source");
1246 }
1247 }
1248
1249 mod properties {
1250 use super::*;
1251
1252 #[test]
1253 fn test_id_returns_correct_value() {
1254 let source = PostgresSourceBuilder::new("my-pg-source")
1255 .with_database("db")
1256 .with_user("user")
1257 .build()
1258 .unwrap();
1259 assert_eq!(source.id(), "my-pg-source");
1260 }
1261
1262 #[test]
1263 fn test_type_name_returns_postgres() {
1264 let source = PostgresSourceBuilder::new("test")
1265 .with_database("db")
1266 .with_user("user")
1267 .build()
1268 .unwrap();
1269 assert_eq!(source.type_name(), "postgres");
1270 }
1271
1272 #[test]
1273 fn test_properties_contains_connection_info() {
1274 let source = PostgresSourceBuilder::new("test")
1275 .with_host("db.example.com")
1276 .with_port(5433)
1277 .with_database("mydb")
1278 .with_user("app_user")
1279 .with_password("secret")
1280 .with_tables(vec!["users".to_string()])
1281 .build()
1282 .unwrap();
1283 let props = source.properties();
1284
1285 assert_eq!(
1286 props.get("host"),
1287 Some(&serde_json::Value::String("db.example.com".to_string()))
1288 );
1289 assert_eq!(
1290 props.get("port"),
1291 Some(&serde_json::Value::Number(5433.into()))
1292 );
1293 assert_eq!(
1294 props.get("database"),
1295 Some(&serde_json::Value::String("mydb".to_string()))
1296 );
1297 assert_eq!(
1298 props.get("user"),
1299 Some(&serde_json::Value::String("app_user".to_string()))
1300 );
1301 }
1302
1303 #[test]
1304 fn test_properties_includes_password() {
1305 let source = PostgresSourceBuilder::new("test")
1306 .with_database("db")
1307 .with_user("user")
1308 .with_password("super_secret_password")
1309 .build()
1310 .unwrap();
1311 let props = source.properties();
1312
1313 assert_eq!(
1315 props.get("password"),
1316 Some(&serde_json::Value::String(
1317 "super_secret_password".to_string()
1318 ))
1319 );
1320 }
1321
1322 #[test]
1323 fn test_properties_includes_tables() {
1324 let source = PostgresSourceBuilder::new("test")
1325 .with_database("db")
1326 .with_user("user")
1327 .with_tables(vec!["users".to_string(), "orders".to_string()])
1328 .build()
1329 .unwrap();
1330 let props = source.properties();
1331
1332 let tables = props.get("tables").unwrap().as_array().unwrap();
1333 assert_eq!(tables.len(), 2);
1334 assert_eq!(tables[0], "users");
1335 assert_eq!(tables[1], "orders");
1336 }
1337
1338 #[test]
1339 fn test_describe_schema_falls_back_to_configured_tables() {
1340 let source = PostgresSourceBuilder::new("test")
1341 .with_database("db")
1342 .with_user("user")
1343 .with_tables(vec!["public.users".to_string(), "orders".to_string()])
1344 .build()
1345 .unwrap();
1346
1347 let schema = source
1348 .describe_schema()
1349 .expect("configured postgres tables should produce fallback schema");
1350
1351 assert_eq!(schema.nodes.len(), 2);
1352 assert!(schema.nodes.iter().any(|node| node.label == "users"));
1353 assert!(schema.nodes.iter().any(|node| node.label == "orders"));
1354 }
1355
1356 #[test]
1357 fn test_postgres_type_to_property_type_integer() {
1358 assert_eq!(
1359 postgres_type_to_property_type("integer"),
1360 Some(PropertyType::Integer)
1361 );
1362 assert_eq!(
1363 postgres_type_to_property_type("bigint"),
1364 Some(PropertyType::Integer)
1365 );
1366 assert_eq!(
1367 postgres_type_to_property_type("smallint"),
1368 Some(PropertyType::Integer)
1369 );
1370 }
1371
1372 #[test]
1373 fn test_postgres_type_to_property_type_float() {
1374 assert_eq!(
1375 postgres_type_to_property_type("double precision"),
1376 Some(PropertyType::Float)
1377 );
1378 assert_eq!(
1379 postgres_type_to_property_type("real"),
1380 Some(PropertyType::Float)
1381 );
1382 assert_eq!(
1383 postgres_type_to_property_type("numeric"),
1384 Some(PropertyType::Float)
1385 );
1386 assert_eq!(
1387 postgres_type_to_property_type("decimal"),
1388 Some(PropertyType::Float)
1389 );
1390 }
1391
1392 #[test]
1393 fn test_postgres_type_to_property_type_boolean() {
1394 assert_eq!(
1395 postgres_type_to_property_type("boolean"),
1396 Some(PropertyType::Boolean)
1397 );
1398 }
1399
1400 #[test]
1401 fn test_postgres_type_to_property_type_timestamp() {
1402 assert_eq!(
1403 postgres_type_to_property_type("timestamp with time zone"),
1404 Some(PropertyType::Timestamp)
1405 );
1406 assert_eq!(
1407 postgres_type_to_property_type("timestamp without time zone"),
1408 Some(PropertyType::Timestamp)
1409 );
1410 assert_eq!(
1411 postgres_type_to_property_type("date"),
1412 Some(PropertyType::Timestamp)
1413 );
1414 }
1415
1416 #[test]
1417 fn test_postgres_type_to_property_type_json() {
1418 assert_eq!(
1419 postgres_type_to_property_type("json"),
1420 Some(PropertyType::Json)
1421 );
1422 assert_eq!(
1423 postgres_type_to_property_type("jsonb"),
1424 Some(PropertyType::Json)
1425 );
1426 }
1427
1428 #[test]
1429 fn test_postgres_type_to_property_type_string() {
1430 assert_eq!(
1431 postgres_type_to_property_type("character varying"),
1432 Some(PropertyType::String)
1433 );
1434 assert_eq!(
1435 postgres_type_to_property_type("text"),
1436 Some(PropertyType::String)
1437 );
1438 assert_eq!(
1439 postgres_type_to_property_type("uuid"),
1440 Some(PropertyType::String)
1441 );
1442 }
1443
1444 #[test]
1445 fn test_postgres_type_to_property_type_unknown_returns_none() {
1446 assert_eq!(postgres_type_to_property_type("point"), None);
1447 assert_eq!(postgres_type_to_property_type("polygon"), None);
1448 assert_eq!(postgres_type_to_property_type("cidr"), None);
1449 }
1450 }
1451
1452 mod lifecycle {
1453 use super::*;
1454
1455 struct TestSecretResolver;
1457
1458 #[async_trait::async_trait]
1459 impl drasi_plugin_sdk::resolver::ValueResolver for TestSecretResolver {
1460 async fn resolve_to_string(
1461 &self,
1462 value: &drasi_plugin_sdk::ConfigValue<String>,
1463 ) -> Result<String, drasi_plugin_sdk::resolver::ResolverError> {
1464 match value {
1465 drasi_plugin_sdk::ConfigValue::Secret { name } => {
1466 Ok(format!("resolved-secret-{name}"))
1467 }
1468 _ => Err(drasi_plugin_sdk::resolver::ResolverError::WrongResolverType),
1469 }
1470 }
1471 }
1472
1473 fn ensure_test_secret_resolver() {
1474 drasi_plugin_sdk::resolver::register_secret_resolver(std::sync::Arc::new(
1475 TestSecretResolver,
1476 ));
1477 }
1478
1479 #[tokio::test]
1480 async fn test_descriptor_preserves_secret_envelope() {
1481 use crate::descriptor::PostgresSourceDescriptor;
1482 use drasi_lib::sources::Source;
1483 use drasi_plugin_sdk::descriptor::SourcePluginDescriptor;
1484
1485 ensure_test_secret_resolver();
1486
1487 let config_json = serde_json::json!({
1488 "host": "db.example.com",
1489 "port": 5432,
1490 "database": "mydb",
1491 "user": "app_user",
1492 "password": {
1493 "kind": "Secret",
1494 "name": "db-password"
1495 },
1496 "tables": ["users"],
1497 "slotName": "drasi_slot",
1498 "publicationName": "drasi_pub"
1499 });
1500
1501 let descriptor = PostgresSourceDescriptor;
1502 let source = descriptor
1503 .create_source("pg-secret-test", &config_json, true)
1504 .await
1505 .expect("descriptor should create source");
1506
1507 let props = source.properties();
1508
1509 let password = props.get("password").expect("password must be present");
1511 assert!(
1512 password.is_object(),
1513 "password should be Secret envelope, got: {password}"
1514 );
1515 assert_eq!(
1516 password.get("kind").and_then(|v| v.as_str()),
1517 Some("Secret"),
1518 "envelope kind must be Secret"
1519 );
1520 assert_eq!(
1521 password.get("name").and_then(|v| v.as_str()),
1522 Some("db-password"),
1523 "secret name must be preserved"
1524 );
1525
1526 let props_str = serde_json::to_string(&props).unwrap();
1528 assert!(
1529 !props_str.contains("resolved-secret-db-password"),
1530 "resolved secret must not appear in properties"
1531 );
1532
1533 assert!(
1535 props.contains_key("slotName"),
1536 "expected camelCase 'slotName', got keys: {:?}",
1537 props.keys().collect::<Vec<_>>()
1538 );
1539 assert!(
1540 props.contains_key("publicationName"),
1541 "expected camelCase 'publicationName'"
1542 );
1543 }
1544
1545 #[tokio::test]
1546 async fn test_initial_status_is_stopped() {
1547 let source = PostgresSourceBuilder::new("test")
1548 .with_database("db")
1549 .with_user("user")
1550 .build()
1551 .unwrap();
1552 assert_eq!(source.status().await, ComponentStatus::Stopped);
1553 }
1554
1555 #[test]
1556 fn test_builder_fallback_produces_camel_case() {
1557 use drasi_lib::sources::Source;
1558
1559 let source = PostgresSourceBuilder::new("pg-fallback")
1560 .with_host("myhost.example.com")
1561 .with_port(5433)
1562 .with_database("mydb")
1563 .with_user("admin")
1564 .with_password("secret123")
1565 .with_ssl_mode(SslMode::Require)
1566 .with_slot_name("custom_slot")
1567 .with_publication_name("custom_pub")
1568 .build()
1569 .unwrap();
1570
1571 let props = source.properties();
1572
1573 assert!(
1575 props.contains_key("slotName"),
1576 "expected camelCase 'slotName', got keys: {:?}",
1577 props.keys().collect::<Vec<_>>()
1578 );
1579 assert!(
1580 props.contains_key("publicationName"),
1581 "expected camelCase 'publicationName'"
1582 );
1583 assert!(
1584 props.contains_key("sslMode"),
1585 "expected camelCase 'sslMode'"
1586 );
1587
1588 assert!(
1590 !props.contains_key("slot_name"),
1591 "should not have snake_case 'slot_name'"
1592 );
1593 assert!(
1594 !props.contains_key("publication_name"),
1595 "should not have snake_case 'publication_name'"
1596 );
1597
1598 assert_eq!(
1600 props.get("host").and_then(|v| v.as_str()),
1601 Some("myhost.example.com")
1602 );
1603 assert_eq!(props.get("port").and_then(|v| v.as_u64()), Some(5433));
1604 assert_eq!(props.get("database").and_then(|v| v.as_str()), Some("mydb"));
1605 assert_eq!(
1606 props.get("password").and_then(|v| v.as_str()),
1607 Some("secret123")
1608 );
1609 }
1610
1611 #[tokio::test]
1612 async fn test_pause_replication_for_restart_aborts_existing_task() {
1613 let source = PostgresSourceBuilder::new("test")
1614 .with_database("db")
1615 .with_user("user")
1616 .build()
1617 .unwrap();
1618
1619 source.base.set_status(ComponentStatus::Running, None).await;
1620
1621 let task = tokio::spawn(async {
1622 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1623 });
1624 *source.base.task_handle.write().await = Some(task);
1625
1626 source.pause_replication_for_restart(42).await;
1627
1628 assert!(source.base.task_handle.read().await.is_none());
1629 assert_eq!(source.status().await, ComponentStatus::Starting);
1630 }
1631
1632 #[test]
1633 fn test_supports_replay_returns_true() {
1634 let source = PostgresSourceBuilder::new("test")
1635 .with_database("db")
1636 .with_user("user")
1637 .build()
1638 .unwrap();
1639 assert!(source.supports_replay());
1640 }
1641 }
1642
1643 mod subscribe {
1644 use super::*;
1645 use drasi_lib::config::SourceSubscriptionSettings;
1646 use std::collections::HashSet;
1647
1648 #[tokio::test]
1649 async fn test_malformed_resume_from_rejected() {
1650 let source = PostgresSourceBuilder::new("test-source")
1651 .with_database("testdb")
1652 .with_user("testuser")
1653 .build()
1654 .unwrap();
1655
1656 let bad_position = bytes::Bytes::from(vec![0u8; 4]);
1658 let settings = SourceSubscriptionSettings {
1659 source_id: "test-source".to_string(),
1660 query_id: "q-bad-position".to_string(),
1661 enable_bootstrap: false,
1662 nodes: HashSet::new(),
1663 relations: HashSet::new(),
1664 resume_from: Some(bad_position),
1665 request_position_handle: false,
1666 };
1667
1668 let result = source.subscribe(settings).await;
1669 assert!(result.is_err());
1670 let err_msg = format!("{}", result.err().unwrap());
1671 assert!(
1672 err_msg.contains("expected 8 bytes"),
1673 "Error should mention expected byte length, got: {err_msg}"
1674 );
1675 }
1676 }
1677
1678 mod builder {
1679 use super::*;
1680
1681 #[test]
1682 fn test_postgres_builder_defaults() {
1683 let source = PostgresSourceBuilder::new("test").build().unwrap();
1684 assert_eq!(source.config.host, "localhost");
1685 assert_eq!(source.config.port, 5432);
1686 assert_eq!(source.config.slot_name, "drasi_slot");
1687 assert_eq!(source.config.publication_name, "drasi_publication");
1688 }
1689
1690 #[test]
1691 fn test_postgres_builder_custom_values() {
1692 let source = PostgresSourceBuilder::new("test")
1693 .with_host("db.example.com")
1694 .with_port(5433)
1695 .with_database("production")
1696 .with_user("app_user")
1697 .with_password("secret")
1698 .with_tables(vec!["users".to_string(), "orders".to_string()])
1699 .build()
1700 .unwrap();
1701
1702 assert_eq!(source.config.host, "db.example.com");
1703 assert_eq!(source.config.port, 5433);
1704 assert_eq!(source.config.database, "production");
1705 assert_eq!(source.config.user, "app_user");
1706 assert_eq!(source.config.password, "secret");
1707 assert_eq!(source.config.tables.len(), 2);
1708 assert_eq!(source.config.tables[0], "users");
1709 assert_eq!(source.config.tables[1], "orders");
1710 }
1711
1712 #[test]
1713 fn test_builder_add_table() {
1714 let source = PostgresSourceBuilder::new("test")
1715 .add_table("table1")
1716 .add_table("table2")
1717 .add_table("table3")
1718 .build()
1719 .unwrap();
1720
1721 assert_eq!(source.config.tables.len(), 3);
1722 assert_eq!(source.config.tables[0], "table1");
1723 assert_eq!(source.config.tables[1], "table2");
1724 assert_eq!(source.config.tables[2], "table3");
1725 }
1726
1727 #[test]
1728 fn test_builder_slot_and_publication() {
1729 let source = PostgresSourceBuilder::new("test")
1730 .with_slot_name("custom_slot")
1731 .with_publication_name("custom_pub")
1732 .build()
1733 .unwrap();
1734
1735 assert_eq!(source.config.slot_name, "custom_slot");
1736 assert_eq!(source.config.publication_name, "custom_pub");
1737 }
1738
1739 #[test]
1740 fn test_builder_id() {
1741 let source = PostgresReplicationSource::builder("my-pg-source")
1742 .with_database("db")
1743 .with_user("user")
1744 .build()
1745 .unwrap();
1746
1747 assert_eq!(source.base.id, "my-pg-source");
1748 }
1749 }
1750
1751 mod config {
1752 use super::*;
1753
1754 #[test]
1755 fn test_config_serialization() {
1756 let config = PostgresSourceConfig {
1757 host: "localhost".to_string(),
1758 port: 5432,
1759 database: "testdb".to_string(),
1760 user: "testuser".to_string(),
1761 password: String::new(),
1762 tables: Vec::new(),
1763 slot_name: "drasi_slot".to_string(),
1764 publication_name: "drasi_publication".to_string(),
1765 ssl_mode: SslMode::default(),
1766 table_keys: Vec::new(),
1767 };
1768
1769 let json = serde_json::to_string(&config).unwrap();
1770 let deserialized: PostgresSourceConfig = serde_json::from_str(&json).unwrap();
1771
1772 assert_eq!(config, deserialized);
1773 }
1774
1775 #[test]
1776 fn test_config_deserialization_with_required_fields() {
1777 let json = r#"{
1778 "database": "mydb",
1779 "user": "myuser"
1780 }"#;
1781 let config: PostgresSourceConfig = serde_json::from_str(json).unwrap();
1782
1783 assert_eq!(config.database, "mydb");
1784 assert_eq!(config.user, "myuser");
1785 assert_eq!(config.host, "localhost"); assert_eq!(config.port, 5432); assert_eq!(config.slot_name, "drasi_slot"); }
1789
1790 #[test]
1791 fn test_config_deserialization_full() {
1792 let json = r#"{
1793 "host": "db.prod.internal",
1794 "port": 5433,
1795 "database": "production",
1796 "user": "replication_user",
1797 "password": "secret",
1798 "tables": ["accounts", "transactions"],
1799 "slot_name": "prod_slot",
1800 "publication_name": "prod_publication"
1801 }"#;
1802 let config: PostgresSourceConfig = serde_json::from_str(json).unwrap();
1803
1804 assert_eq!(config.host, "db.prod.internal");
1805 assert_eq!(config.port, 5433);
1806 assert_eq!(config.database, "production");
1807 assert_eq!(config.user, "replication_user");
1808 assert_eq!(config.password, "secret");
1809 assert_eq!(config.tables, vec!["accounts", "transactions"]);
1810 assert_eq!(config.slot_name, "prod_slot");
1811 assert_eq!(config.publication_name, "prod_publication");
1812 }
1813 }
1814}
1815
1816#[cfg(feature = "dynamic-plugin")]
1820drasi_plugin_sdk::export_plugin!(
1821 plugin_id = "postgres-source",
1822 core_version = env!("CARGO_PKG_VERSION"),
1823 lib_version = env!("CARGO_PKG_VERSION"),
1824 plugin_version = env!("CARGO_PKG_VERSION"),
1825 source_descriptors = [descriptor::PostgresSourceDescriptor],
1826 reaction_descriptors = [],
1827 bootstrap_descriptors = [],
1828);