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::commit_position_bytes(
896 earliest_available,
897 0,
898 )),
899 }
900 .into());
901 }
902
903 let read_lsn = self.replay_state.current_read_lsn();
904 let is_running = self.base.get_status().await == ComponentStatus::Running;
905
906 if !is_running || read_lsn == 0 || resume_lsn < read_lsn {
907 restart_from = Some(resume_lsn);
908 pause_before_subscribe = is_running;
909 }
910 }
911
912 if let Some(start_lsn) = restart_from.filter(|_| pause_before_subscribe) {
913 self.pause_replication_for_restart(start_lsn).await;
917 }
918
919 let response = match self
920 .base
921 .subscribe_with_bootstrap(&settings, "PostgreSQL")
922 .await
923 {
924 Ok(response) => response,
925 Err(err) => {
926 if pause_before_subscribe {
927 self.base
928 .set_status(
929 ComponentStatus::Error,
930 Some(format!("Failed to register replay subscription: {err}")),
931 )
932 .await;
933 }
934 return Err(err);
935 }
936 };
937
938 if let Some(start_lsn) = restart_from {
939 if pause_before_subscribe {
940 self.resume_replication_from(start_lsn).await?;
941 } else {
942 self.restart_replication_from(start_lsn).await?;
943 }
944 }
945
946 Ok(response)
947 }
948
949 fn as_any(&self) -> &dyn std::any::Any {
950 self
951 }
952
953 async fn initialize(&self, context: drasi_lib::context::SourceRuntimeContext) {
954 self.base.initialize(context).await;
955 self.base
960 .set_position_comparator(drasi_lib::sources::ByteLexPositionComparator)
961 .await;
962 }
963
964 async fn remove_position_handle(&self, query_id: &str) {
965 self.base.remove_position_handle(query_id).await;
966 }
967
968 async fn on_subscriptions_complete(&self) {
969 self.replay_state.clear_flush_fence();
972 }
973
974 async fn set_bootstrap_provider(
975 &self,
976 provider: Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>,
977 ) {
978 self.base.set_bootstrap_provider(provider).await;
979 }
980}
981
982pub struct PostgresSourceBuilder {
1005 id: String,
1006 host: String,
1007 port: u16,
1008 database: String,
1009 user: String,
1010 password: String,
1011 tables: Vec<String>,
1012 slot_name: String,
1013 publication_name: String,
1014 ssl_mode: SslMode,
1015 table_keys: Vec<TableKeyConfig>,
1016 dispatch_mode: Option<DispatchMode>,
1017 dispatch_buffer_capacity: Option<usize>,
1018 bootstrap_provider: Option<Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>>,
1019 auto_start: bool,
1020}
1021
1022impl PostgresSourceBuilder {
1023 pub fn new(id: impl Into<String>) -> Self {
1025 Self {
1026 id: id.into(),
1027 host: "localhost".to_string(),
1028 port: 5432,
1029 database: String::new(),
1030 user: String::new(),
1031 password: String::new(),
1032 tables: Vec::new(),
1033 slot_name: "drasi_slot".to_string(),
1034 publication_name: "drasi_publication".to_string(),
1035 ssl_mode: SslMode::default(),
1036 table_keys: Vec::new(),
1037 dispatch_mode: None,
1038 dispatch_buffer_capacity: None,
1039 bootstrap_provider: None,
1040 auto_start: true,
1041 }
1042 }
1043
1044 pub fn with_host(mut self, host: impl Into<String>) -> Self {
1046 self.host = host.into();
1047 self
1048 }
1049
1050 pub fn with_port(mut self, port: u16) -> Self {
1052 self.port = port;
1053 self
1054 }
1055
1056 pub fn with_database(mut self, database: impl Into<String>) -> Self {
1058 self.database = database.into();
1059 self
1060 }
1061
1062 pub fn with_user(mut self, user: impl Into<String>) -> Self {
1064 self.user = user.into();
1065 self
1066 }
1067
1068 pub fn with_password(mut self, password: impl Into<String>) -> Self {
1070 self.password = password.into();
1071 self
1072 }
1073
1074 pub fn with_tables(mut self, tables: Vec<String>) -> Self {
1076 self.tables = tables;
1077 self
1078 }
1079
1080 pub fn add_table(mut self, table: impl Into<String>) -> Self {
1082 self.tables.push(table.into());
1083 self
1084 }
1085
1086 pub fn with_slot_name(mut self, slot_name: impl Into<String>) -> Self {
1088 self.slot_name = slot_name.into();
1089 self
1090 }
1091
1092 pub fn with_publication_name(mut self, publication_name: impl Into<String>) -> Self {
1094 self.publication_name = publication_name.into();
1095 self
1096 }
1097
1098 pub fn with_ssl_mode(mut self, ssl_mode: SslMode) -> Self {
1100 self.ssl_mode = ssl_mode;
1101 self
1102 }
1103
1104 pub fn with_table_keys(mut self, table_keys: Vec<TableKeyConfig>) -> Self {
1106 self.table_keys = table_keys;
1107 self
1108 }
1109
1110 pub fn add_table_key(mut self, table_key: TableKeyConfig) -> Self {
1112 self.table_keys.push(table_key);
1113 self
1114 }
1115
1116 pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
1118 self.dispatch_mode = Some(mode);
1119 self
1120 }
1121
1122 pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
1124 self.dispatch_buffer_capacity = Some(capacity);
1125 self
1126 }
1127
1128 pub fn with_bootstrap_provider(
1130 mut self,
1131 provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
1132 ) -> Self {
1133 self.bootstrap_provider = Some(Box::new(provider));
1134 self
1135 }
1136
1137 pub fn with_auto_start(mut self, auto_start: bool) -> Self {
1142 self.auto_start = auto_start;
1143 self
1144 }
1145
1146 pub fn with_config(mut self, config: PostgresSourceConfig) -> Self {
1148 self.host = config.host;
1149 self.port = config.port;
1150 self.database = config.database;
1151 self.user = config.user;
1152 self.password = config.password;
1153 self.tables = config.tables;
1154 self.slot_name = config.slot_name;
1155 self.publication_name = config.publication_name;
1156 self.ssl_mode = config.ssl_mode;
1157 self.table_keys = config.table_keys;
1158 self
1159 }
1160
1161 pub fn build(self) -> Result<PostgresReplicationSource> {
1167 let config = PostgresSourceConfig {
1168 host: self.host,
1169 port: self.port,
1170 database: self.database,
1171 user: self.user,
1172 password: self.password,
1173 tables: self.tables,
1174 slot_name: self.slot_name,
1175 publication_name: self.publication_name,
1176 ssl_mode: self.ssl_mode,
1177 table_keys: self.table_keys,
1178 };
1179
1180 let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
1181 if let Some(mode) = self.dispatch_mode {
1182 params = params.with_dispatch_mode(mode);
1183 }
1184 if let Some(capacity) = self.dispatch_buffer_capacity {
1185 params = params.with_dispatch_buffer_capacity(capacity);
1186 }
1187 if let Some(provider) = self.bootstrap_provider {
1188 params = params.with_bootstrap_provider(provider);
1189 }
1190
1191 Ok(PostgresReplicationSource {
1192 base: SourceBase::new(params)?,
1193 config,
1194 cached_schema: Arc::new(std::sync::RwLock::new(None)),
1195 replay_state: Arc::new(ReplayState::default()),
1196 subscribe_lock: Mutex::new(()),
1197 })
1198 }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204
1205 mod construction {
1206 use super::*;
1207
1208 #[test]
1209 fn test_builder_with_valid_config() {
1210 let source = PostgresSourceBuilder::new("test-source")
1211 .with_database("testdb")
1212 .with_user("testuser")
1213 .build();
1214 assert!(source.is_ok());
1215 }
1216
1217 #[test]
1218 fn test_builder_with_custom_config() {
1219 let source = PostgresSourceBuilder::new("pg-source")
1220 .with_host("192.168.1.100")
1221 .with_port(5433)
1222 .with_database("production")
1223 .with_user("admin")
1224 .with_password("secret")
1225 .build()
1226 .unwrap();
1227 assert_eq!(source.id(), "pg-source");
1228 }
1229
1230 #[test]
1231 fn test_with_dispatch_creates_source() {
1232 let config = PostgresSourceConfig {
1233 host: "localhost".to_string(),
1234 port: 5432,
1235 database: "testdb".to_string(),
1236 user: "testuser".to_string(),
1237 password: String::new(),
1238 tables: Vec::new(),
1239 slot_name: "drasi_slot".to_string(),
1240 publication_name: "drasi_publication".to_string(),
1241 ssl_mode: SslMode::default(),
1242 table_keys: Vec::new(),
1243 };
1244 let source = PostgresReplicationSource::with_dispatch(
1245 "dispatch-source",
1246 config,
1247 Some(DispatchMode::Channel),
1248 Some(2000),
1249 );
1250 assert!(source.is_ok());
1251 assert_eq!(source.unwrap().id(), "dispatch-source");
1252 }
1253 }
1254
1255 mod properties {
1256 use super::*;
1257
1258 #[test]
1259 fn test_id_returns_correct_value() {
1260 let source = PostgresSourceBuilder::new("my-pg-source")
1261 .with_database("db")
1262 .with_user("user")
1263 .build()
1264 .unwrap();
1265 assert_eq!(source.id(), "my-pg-source");
1266 }
1267
1268 #[test]
1269 fn test_type_name_returns_postgres() {
1270 let source = PostgresSourceBuilder::new("test")
1271 .with_database("db")
1272 .with_user("user")
1273 .build()
1274 .unwrap();
1275 assert_eq!(source.type_name(), "postgres");
1276 }
1277
1278 #[test]
1279 fn test_properties_contains_connection_info() {
1280 let source = PostgresSourceBuilder::new("test")
1281 .with_host("db.example.com")
1282 .with_port(5433)
1283 .with_database("mydb")
1284 .with_user("app_user")
1285 .with_password("secret")
1286 .with_tables(vec!["users".to_string()])
1287 .build()
1288 .unwrap();
1289 let props = source.properties();
1290
1291 assert_eq!(
1292 props.get("host"),
1293 Some(&serde_json::Value::String("db.example.com".to_string()))
1294 );
1295 assert_eq!(
1296 props.get("port"),
1297 Some(&serde_json::Value::Number(5433.into()))
1298 );
1299 assert_eq!(
1300 props.get("database"),
1301 Some(&serde_json::Value::String("mydb".to_string()))
1302 );
1303 assert_eq!(
1304 props.get("user"),
1305 Some(&serde_json::Value::String("app_user".to_string()))
1306 );
1307 }
1308
1309 #[test]
1310 fn test_properties_includes_password() {
1311 let source = PostgresSourceBuilder::new("test")
1312 .with_database("db")
1313 .with_user("user")
1314 .with_password("super_secret_password")
1315 .build()
1316 .unwrap();
1317 let props = source.properties();
1318
1319 assert_eq!(
1321 props.get("password"),
1322 Some(&serde_json::Value::String(
1323 "super_secret_password".to_string()
1324 ))
1325 );
1326 }
1327
1328 #[test]
1329 fn test_properties_includes_tables() {
1330 let source = PostgresSourceBuilder::new("test")
1331 .with_database("db")
1332 .with_user("user")
1333 .with_tables(vec!["users".to_string(), "orders".to_string()])
1334 .build()
1335 .unwrap();
1336 let props = source.properties();
1337
1338 let tables = props.get("tables").unwrap().as_array().unwrap();
1339 assert_eq!(tables.len(), 2);
1340 assert_eq!(tables[0], "users");
1341 assert_eq!(tables[1], "orders");
1342 }
1343
1344 #[test]
1345 fn test_describe_schema_falls_back_to_configured_tables() {
1346 let source = PostgresSourceBuilder::new("test")
1347 .with_database("db")
1348 .with_user("user")
1349 .with_tables(vec!["public.users".to_string(), "orders".to_string()])
1350 .build()
1351 .unwrap();
1352
1353 let schema = source
1354 .describe_schema()
1355 .expect("configured postgres tables should produce fallback schema");
1356
1357 assert_eq!(schema.nodes.len(), 2);
1358 assert!(schema.nodes.iter().any(|node| node.label == "users"));
1359 assert!(schema.nodes.iter().any(|node| node.label == "orders"));
1360 }
1361
1362 #[test]
1363 fn test_postgres_type_to_property_type_integer() {
1364 assert_eq!(
1365 postgres_type_to_property_type("integer"),
1366 Some(PropertyType::Integer)
1367 );
1368 assert_eq!(
1369 postgres_type_to_property_type("bigint"),
1370 Some(PropertyType::Integer)
1371 );
1372 assert_eq!(
1373 postgres_type_to_property_type("smallint"),
1374 Some(PropertyType::Integer)
1375 );
1376 }
1377
1378 #[test]
1379 fn test_postgres_type_to_property_type_float() {
1380 assert_eq!(
1381 postgres_type_to_property_type("double precision"),
1382 Some(PropertyType::Float)
1383 );
1384 assert_eq!(
1385 postgres_type_to_property_type("real"),
1386 Some(PropertyType::Float)
1387 );
1388 assert_eq!(
1389 postgres_type_to_property_type("numeric"),
1390 Some(PropertyType::Float)
1391 );
1392 assert_eq!(
1393 postgres_type_to_property_type("decimal"),
1394 Some(PropertyType::Float)
1395 );
1396 }
1397
1398 #[test]
1399 fn test_postgres_type_to_property_type_boolean() {
1400 assert_eq!(
1401 postgres_type_to_property_type("boolean"),
1402 Some(PropertyType::Boolean)
1403 );
1404 }
1405
1406 #[test]
1407 fn test_postgres_type_to_property_type_timestamp() {
1408 assert_eq!(
1409 postgres_type_to_property_type("timestamp with time zone"),
1410 Some(PropertyType::Timestamp)
1411 );
1412 assert_eq!(
1413 postgres_type_to_property_type("timestamp without time zone"),
1414 Some(PropertyType::Timestamp)
1415 );
1416 assert_eq!(
1417 postgres_type_to_property_type("date"),
1418 Some(PropertyType::Timestamp)
1419 );
1420 }
1421
1422 #[test]
1423 fn test_postgres_type_to_property_type_json() {
1424 assert_eq!(
1425 postgres_type_to_property_type("json"),
1426 Some(PropertyType::Json)
1427 );
1428 assert_eq!(
1429 postgres_type_to_property_type("jsonb"),
1430 Some(PropertyType::Json)
1431 );
1432 }
1433
1434 #[test]
1435 fn test_postgres_type_to_property_type_string() {
1436 assert_eq!(
1437 postgres_type_to_property_type("character varying"),
1438 Some(PropertyType::String)
1439 );
1440 assert_eq!(
1441 postgres_type_to_property_type("text"),
1442 Some(PropertyType::String)
1443 );
1444 assert_eq!(
1445 postgres_type_to_property_type("uuid"),
1446 Some(PropertyType::String)
1447 );
1448 }
1449
1450 #[test]
1451 fn test_postgres_type_to_property_type_unknown_returns_none() {
1452 assert_eq!(postgres_type_to_property_type("point"), None);
1453 assert_eq!(postgres_type_to_property_type("polygon"), None);
1454 assert_eq!(postgres_type_to_property_type("cidr"), None);
1455 }
1456 }
1457
1458 mod lifecycle {
1459 use super::*;
1460
1461 struct TestSecretResolver;
1463
1464 #[async_trait::async_trait]
1465 impl drasi_plugin_sdk::resolver::ValueResolver for TestSecretResolver {
1466 async fn resolve_to_string(
1467 &self,
1468 value: &drasi_plugin_sdk::ConfigValue<String>,
1469 ) -> Result<String, drasi_plugin_sdk::resolver::ResolverError> {
1470 match value {
1471 drasi_plugin_sdk::ConfigValue::Secret { name } => {
1472 Ok(format!("resolved-secret-{name}"))
1473 }
1474 _ => Err(drasi_plugin_sdk::resolver::ResolverError::WrongResolverType),
1475 }
1476 }
1477 }
1478
1479 fn ensure_test_secret_resolver() {
1480 drasi_plugin_sdk::resolver::register_secret_resolver(std::sync::Arc::new(
1481 TestSecretResolver,
1482 ));
1483 }
1484
1485 #[tokio::test]
1486 async fn test_descriptor_preserves_secret_envelope() {
1487 use crate::descriptor::PostgresSourceDescriptor;
1488 use drasi_lib::sources::Source;
1489 use drasi_plugin_sdk::descriptor::SourcePluginDescriptor;
1490
1491 ensure_test_secret_resolver();
1492
1493 let config_json = serde_json::json!({
1494 "host": "db.example.com",
1495 "port": 5432,
1496 "database": "mydb",
1497 "user": "app_user",
1498 "password": {
1499 "kind": "Secret",
1500 "name": "db-password"
1501 },
1502 "tables": ["users"],
1503 "slotName": "drasi_slot",
1504 "publicationName": "drasi_pub"
1505 });
1506
1507 let descriptor = PostgresSourceDescriptor;
1508 let source = descriptor
1509 .create_source("pg-secret-test", &config_json, true)
1510 .await
1511 .expect("descriptor should create source");
1512
1513 let props = source.properties();
1514
1515 let password = props.get("password").expect("password must be present");
1517 assert!(
1518 password.is_object(),
1519 "password should be Secret envelope, got: {password}"
1520 );
1521 assert_eq!(
1522 password.get("kind").and_then(|v| v.as_str()),
1523 Some("Secret"),
1524 "envelope kind must be Secret"
1525 );
1526 assert_eq!(
1527 password.get("name").and_then(|v| v.as_str()),
1528 Some("db-password"),
1529 "secret name must be preserved"
1530 );
1531
1532 let props_str = serde_json::to_string(&props).unwrap();
1534 assert!(
1535 !props_str.contains("resolved-secret-db-password"),
1536 "resolved secret must not appear in properties"
1537 );
1538
1539 assert!(
1541 props.contains_key("slotName"),
1542 "expected camelCase 'slotName', got keys: {:?}",
1543 props.keys().collect::<Vec<_>>()
1544 );
1545 assert!(
1546 props.contains_key("publicationName"),
1547 "expected camelCase 'publicationName'"
1548 );
1549 }
1550
1551 #[tokio::test]
1552 async fn test_initial_status_is_stopped() {
1553 let source = PostgresSourceBuilder::new("test")
1554 .with_database("db")
1555 .with_user("user")
1556 .build()
1557 .unwrap();
1558 assert_eq!(source.status().await, ComponentStatus::Stopped);
1559 }
1560
1561 #[test]
1562 fn test_builder_fallback_produces_camel_case() {
1563 use drasi_lib::sources::Source;
1564
1565 let source = PostgresSourceBuilder::new("pg-fallback")
1566 .with_host("myhost.example.com")
1567 .with_port(5433)
1568 .with_database("mydb")
1569 .with_user("admin")
1570 .with_password("secret123")
1571 .with_ssl_mode(SslMode::Require)
1572 .with_slot_name("custom_slot")
1573 .with_publication_name("custom_pub")
1574 .build()
1575 .unwrap();
1576
1577 let props = source.properties();
1578
1579 assert!(
1581 props.contains_key("slotName"),
1582 "expected camelCase 'slotName', got keys: {:?}",
1583 props.keys().collect::<Vec<_>>()
1584 );
1585 assert!(
1586 props.contains_key("publicationName"),
1587 "expected camelCase 'publicationName'"
1588 );
1589 assert!(
1590 props.contains_key("sslMode"),
1591 "expected camelCase 'sslMode'"
1592 );
1593
1594 assert!(
1596 !props.contains_key("slot_name"),
1597 "should not have snake_case 'slot_name'"
1598 );
1599 assert!(
1600 !props.contains_key("publication_name"),
1601 "should not have snake_case 'publication_name'"
1602 );
1603
1604 assert_eq!(
1606 props.get("host").and_then(|v| v.as_str()),
1607 Some("myhost.example.com")
1608 );
1609 assert_eq!(props.get("port").and_then(|v| v.as_u64()), Some(5433));
1610 assert_eq!(props.get("database").and_then(|v| v.as_str()), Some("mydb"));
1611 assert_eq!(
1612 props.get("password").and_then(|v| v.as_str()),
1613 Some("secret123")
1614 );
1615 }
1616
1617 #[tokio::test]
1618 async fn test_pause_replication_for_restart_aborts_existing_task() {
1619 let source = PostgresSourceBuilder::new("test")
1620 .with_database("db")
1621 .with_user("user")
1622 .build()
1623 .unwrap();
1624
1625 source.base.set_status(ComponentStatus::Running, None).await;
1626
1627 let task = tokio::spawn(async {
1628 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1629 });
1630 *source.base.task_handle.write().await = Some(task);
1631
1632 source.pause_replication_for_restart(42).await;
1633
1634 assert!(source.base.task_handle.read().await.is_none());
1635 assert_eq!(source.status().await, ComponentStatus::Starting);
1636 }
1637
1638 #[test]
1639 fn test_supports_replay_returns_true() {
1640 let source = PostgresSourceBuilder::new("test")
1641 .with_database("db")
1642 .with_user("user")
1643 .build()
1644 .unwrap();
1645 assert!(source.supports_replay());
1646 }
1647 }
1648
1649 mod subscribe {
1650 use super::*;
1651 use drasi_lib::config::SourceSubscriptionSettings;
1652 use std::collections::HashSet;
1653
1654 #[tokio::test]
1655 async fn test_malformed_resume_from_rejected() {
1656 let source = PostgresSourceBuilder::new("test-source")
1657 .with_database("testdb")
1658 .with_user("testuser")
1659 .build()
1660 .unwrap();
1661
1662 let bad_position = bytes::Bytes::from(vec![0u8; 8]);
1665 let settings = SourceSubscriptionSettings {
1666 source_id: "test-source".to_string(),
1667 query_id: "q-bad-position".to_string(),
1668 enable_bootstrap: false,
1669 nodes: HashSet::new(),
1670 relations: HashSet::new(),
1671 resume_from: Some(bad_position),
1672 request_position_handle: false,
1673 };
1674
1675 let result = source.subscribe(settings).await;
1676 assert!(result.is_err());
1677 let err_msg = format!("{}", result.err().unwrap());
1678 assert!(
1679 err_msg.contains("expected 16 bytes"),
1680 "Error should mention expected byte length, got: {err_msg}"
1681 );
1682 }
1683 }
1684
1685 mod builder {
1686 use super::*;
1687
1688 #[test]
1689 fn test_postgres_builder_defaults() {
1690 let source = PostgresSourceBuilder::new("test").build().unwrap();
1691 assert_eq!(source.config.host, "localhost");
1692 assert_eq!(source.config.port, 5432);
1693 assert_eq!(source.config.slot_name, "drasi_slot");
1694 assert_eq!(source.config.publication_name, "drasi_publication");
1695 }
1696
1697 #[test]
1698 fn test_postgres_builder_custom_values() {
1699 let source = PostgresSourceBuilder::new("test")
1700 .with_host("db.example.com")
1701 .with_port(5433)
1702 .with_database("production")
1703 .with_user("app_user")
1704 .with_password("secret")
1705 .with_tables(vec!["users".to_string(), "orders".to_string()])
1706 .build()
1707 .unwrap();
1708
1709 assert_eq!(source.config.host, "db.example.com");
1710 assert_eq!(source.config.port, 5433);
1711 assert_eq!(source.config.database, "production");
1712 assert_eq!(source.config.user, "app_user");
1713 assert_eq!(source.config.password, "secret");
1714 assert_eq!(source.config.tables.len(), 2);
1715 assert_eq!(source.config.tables[0], "users");
1716 assert_eq!(source.config.tables[1], "orders");
1717 }
1718
1719 #[test]
1720 fn test_builder_add_table() {
1721 let source = PostgresSourceBuilder::new("test")
1722 .add_table("table1")
1723 .add_table("table2")
1724 .add_table("table3")
1725 .build()
1726 .unwrap();
1727
1728 assert_eq!(source.config.tables.len(), 3);
1729 assert_eq!(source.config.tables[0], "table1");
1730 assert_eq!(source.config.tables[1], "table2");
1731 assert_eq!(source.config.tables[2], "table3");
1732 }
1733
1734 #[test]
1735 fn test_builder_slot_and_publication() {
1736 let source = PostgresSourceBuilder::new("test")
1737 .with_slot_name("custom_slot")
1738 .with_publication_name("custom_pub")
1739 .build()
1740 .unwrap();
1741
1742 assert_eq!(source.config.slot_name, "custom_slot");
1743 assert_eq!(source.config.publication_name, "custom_pub");
1744 }
1745
1746 #[test]
1747 fn test_builder_id() {
1748 let source = PostgresReplicationSource::builder("my-pg-source")
1749 .with_database("db")
1750 .with_user("user")
1751 .build()
1752 .unwrap();
1753
1754 assert_eq!(source.base.id, "my-pg-source");
1755 }
1756 }
1757
1758 mod config {
1759 use super::*;
1760
1761 #[test]
1762 fn test_config_serialization() {
1763 let config = PostgresSourceConfig {
1764 host: "localhost".to_string(),
1765 port: 5432,
1766 database: "testdb".to_string(),
1767 user: "testuser".to_string(),
1768 password: String::new(),
1769 tables: Vec::new(),
1770 slot_name: "drasi_slot".to_string(),
1771 publication_name: "drasi_publication".to_string(),
1772 ssl_mode: SslMode::default(),
1773 table_keys: Vec::new(),
1774 };
1775
1776 let json = serde_json::to_string(&config).unwrap();
1777 let deserialized: PostgresSourceConfig = serde_json::from_str(&json).unwrap();
1778
1779 assert_eq!(config, deserialized);
1780 }
1781
1782 #[test]
1783 fn test_config_deserialization_with_required_fields() {
1784 let json = r#"{
1785 "database": "mydb",
1786 "user": "myuser"
1787 }"#;
1788 let config: PostgresSourceConfig = serde_json::from_str(json).unwrap();
1789
1790 assert_eq!(config.database, "mydb");
1791 assert_eq!(config.user, "myuser");
1792 assert_eq!(config.host, "localhost"); assert_eq!(config.port, 5432); assert_eq!(config.slot_name, "drasi_slot"); }
1796
1797 #[test]
1798 fn test_config_deserialization_full() {
1799 let json = r#"{
1800 "host": "db.prod.internal",
1801 "port": 5433,
1802 "database": "production",
1803 "user": "replication_user",
1804 "password": "secret",
1805 "tables": ["accounts", "transactions"],
1806 "slot_name": "prod_slot",
1807 "publication_name": "prod_publication"
1808 }"#;
1809 let config: PostgresSourceConfig = serde_json::from_str(json).unwrap();
1810
1811 assert_eq!(config.host, "db.prod.internal");
1812 assert_eq!(config.port, 5433);
1813 assert_eq!(config.database, "production");
1814 assert_eq!(config.user, "replication_user");
1815 assert_eq!(config.password, "secret");
1816 assert_eq!(config.tables, vec!["accounts", "transactions"]);
1817 assert_eq!(config.slot_name, "prod_slot");
1818 assert_eq!(config.publication_name, "prod_publication");
1819 }
1820 }
1821}
1822
1823#[cfg(feature = "dynamic-plugin")]
1827drasi_plugin_sdk::export_plugin!(
1828 plugin_id = "postgres-source",
1829 core_version = env!("CARGO_PKG_VERSION"),
1830 lib_version = env!("CARGO_PKG_VERSION"),
1831 plugin_version = env!("CARGO_PKG_VERSION"),
1832 source_descriptors = [descriptor::PostgresSourceDescriptor],
1833 reaction_descriptors = [],
1834 bootstrap_descriptors = [],
1835);