Skip to main content

drasi_source_postgres/
stream.rs

1// Copyright 2025 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use anyhow::{anyhow, Result};
16use chrono::Utc;
17use log::{debug, error, info, trace, warn};
18use std::collections::HashMap;
19use std::sync::atomic::Ordering;
20use std::sync::Arc;
21use std::time::Duration;
22use tokio::sync::{oneshot, RwLock};
23use tokio::time::{interval, sleep};
24
25use super::connection::{parse_lsn, ReplicationConnection};
26use super::decoder::PgOutputDecoder;
27use super::protocol::BackendMessage;
28use super::types::{StandbyStatusUpdate, WalMessage};
29use super::{PostgresSourceConfig, ReplayState};
30use drasi_core::models::{Element, ElementMetadata, ElementReference, SourceChange};
31use drasi_lib::channels::{ComponentStatus, SourceEvent, SourceEventWrapper};
32use drasi_lib::component_graph::ComponentStatusHandle;
33use drasi_lib::sources::base::SourceBase;
34
35pub struct ReplicationStream {
36    config: PostgresSourceConfig,
37    source_id: String,
38    connection: Option<ReplicationConnection>,
39    decoder: PgOutputDecoder,
40    #[allow(dead_code)]
41    status_handle: ComponentStatusHandle,
42    base: SourceBase,
43    replay_state: Arc<ReplayState>,
44    read_lsn: u64,
45    start_lsn: Option<u64>,
46    last_feedback_time: std::time::Instant,
47    pending_transaction: Option<Vec<(SourceChange, u64)>>,
48    relations: HashMap<u32, RelationMapping>,
49    table_primary_keys: Arc<RwLock<HashMap<String, Vec<String>>>>,
50}
51
52struct RelationMapping {
53    #[allow(dead_code)]
54    table_name: String,
55    #[allow(dead_code)]
56    schema_name: String,
57    label: String,
58}
59
60impl ReplicationStream {
61    pub(crate) fn new(
62        config: PostgresSourceConfig,
63        source_id: String,
64        status_handle: ComponentStatusHandle,
65        base: SourceBase,
66        replay_state: Arc<ReplayState>,
67        start_lsn: Option<u64>,
68    ) -> Self {
69        Self {
70            config,
71            source_id,
72            connection: None,
73            decoder: PgOutputDecoder::new(),
74            status_handle,
75            base,
76            replay_state,
77            read_lsn: 0,
78            start_lsn,
79            last_feedback_time: std::time::Instant::now(),
80            pending_transaction: None,
81            relations: HashMap::new(),
82            table_primary_keys: Arc::new(RwLock::new(HashMap::new())),
83        }
84    }
85
86    // Note: table_primary_keys is initialized empty and remains so.
87    // Element IDs are generated from configured table_keys (in config.table_keys),
88    // or fall back to using all column values if no keys are configured.
89
90    pub async fn run(
91        &mut self,
92        startup_tx: Option<oneshot::Sender<std::result::Result<(), String>>>,
93    ) -> Result<()> {
94        info!("Starting replication stream for source {}", self.source_id);
95
96        // Connect and setup replication
97        if let Err(error) = self.connect_and_setup().await {
98            if let Some(tx) = startup_tx {
99                let _ = tx.send(Err(format!("{error:#}")));
100            }
101            return Err(error);
102        }
103        if let Some(tx) = startup_tx {
104            let _ = tx.send(Ok(()));
105        }
106
107        // Start streaming loop
108        let mut keepalive_interval = interval(Duration::from_secs(10));
109
110        loop {
111            // Check if we should stop
112            {
113                let status = self.status_handle.get_status().await;
114                if status == ComponentStatus::Stopping || status == ComponentStatus::Stopped {
115                    info!("Received stop signal, shutting down replication");
116                    break;
117                }
118            }
119
120            tokio::select! {
121                // Check for replication messages
122                result = self.read_next_message() => {
123                    match result {
124                        Ok(Some(msg)) => {
125                            if let Err(e) = self.handle_message(msg).await {
126                                error!("Error handling message: {e}");
127                                // Attempt recovery
128                                if let Err(e) = self.recover_connection().await {
129                                    error!("Failed to recover connection: {e}");
130                                    return Err(e);
131                                }
132                            }
133                        }
134                        Ok(None) => {
135                            // No message available
136                        }
137                        Err(e) => {
138                            error!("Error reading message: {e}");
139                            // Attempt recovery
140                            if let Err(e) = self.recover_connection().await {
141                                error!("Failed to recover connection: {e}");
142                                return Err(e);
143                            }
144                        }
145                    }
146                }
147
148                // Send periodic keepalives
149                _ = keepalive_interval.tick() => {
150                    if let Err(e) = self.send_feedback(false).await {
151                        warn!("Failed to send keepalive: {e}");
152                    }
153                }
154            }
155        }
156
157        // Clean shutdown
158        self.shutdown().await?;
159        Ok(())
160    }
161
162    async fn connect_and_setup(&mut self) -> Result<()> {
163        info!("Connecting to PostgreSQL for replication");
164
165        // Create connection
166        let mut conn = ReplicationConnection::connect(
167            &self.config.host,
168            self.config.port,
169            &self.config.database,
170            &self.config.user,
171            &self.config.password,
172        )
173        .await?;
174
175        // Identify system
176        let system_info = conn.identify_system().await?;
177        info!("Connected to PostgreSQL system: {system_info:?}");
178
179        // Create or verify replication slot
180        let slot_info = conn
181            .create_replication_slot(&self.config.slot_name, false)
182            .await?;
183        info!("Using replication slot: {slot_info:?}");
184
185        // Parse starting LSN — use explicit start_lsn if provided (replay),
186        // otherwise use the slot's consistent_point.
187        let slot_lsn =
188            if !slot_info.consistent_point.is_empty() && slot_info.consistent_point != "0/0" {
189                parse_lsn(&slot_info.consistent_point)?
190            } else {
191                0
192            };
193        self.read_lsn = self.start_lsn.unwrap_or(slot_lsn);
194        self.replay_state
195            .read_lsn
196            .store(self.read_lsn, Ordering::Release);
197
198        // Build replication options
199        let mut options = HashMap::new();
200        options.insert("proto_version".to_string(), "1".to_string());
201        options.insert(
202            "publication_names".to_string(),
203            self.config.publication_name.clone(),
204        );
205
206        // Start replication
207        conn.start_replication(&self.config.slot_name, Some(self.read_lsn), options)
208            .await?;
209
210        self.connection = Some(conn);
211        info!(
212            "Replication started from read LSN {:x} (slot watermark {:x})",
213            self.read_lsn, slot_lsn
214        );
215
216        Ok(())
217    }
218
219    async fn read_next_message(&mut self) -> Result<Option<BackendMessage>> {
220        if let Some(conn) = &mut self.connection {
221            // Try to read with a short timeout to avoid blocking forever
222            match tokio::time::timeout(Duration::from_millis(100), conn.read_replication_message())
223                .await
224            {
225                Ok(Ok(msg)) => Ok(Some(msg)),
226                Ok(Err(e)) => Err(e),
227                Err(_) => Ok(None), // Timeout, no message available
228            }
229        } else {
230            Err(anyhow!("No connection available"))
231        }
232    }
233
234    async fn handle_message(&mut self, msg: BackendMessage) -> Result<()> {
235        match msg {
236            BackendMessage::CopyData(data) => {
237                self.handle_copy_data(&data).await?;
238            }
239            BackendMessage::PrimaryKeepaliveMessage {
240                wal_end,
241                timestamp: _,
242                reply,
243            } => {
244                self.read_lsn = wal_end;
245                self.replay_state
246                    .read_lsn
247                    .store(self.read_lsn, Ordering::Release);
248                if reply == 1 {
249                    self.send_feedback(true).await?;
250                }
251            }
252            BackendMessage::ErrorResponse(err) => {
253                error!("Server error: {}", err.message);
254                return Err(anyhow!("Server error: {}", err.message));
255            }
256            _ => {
257                trace!("Ignoring message: {msg:?}");
258            }
259        }
260        Ok(())
261    }
262
263    async fn handle_copy_data(&mut self, data: &[u8]) -> Result<()> {
264        if data.is_empty() {
265            return Ok(());
266        }
267
268        // First byte indicates the message type
269        let msg_type = data[0];
270
271        match msg_type {
272            b'w' => {
273                // XLogData message
274                self.handle_xlog_data(&data[1..]).await?;
275            }
276            b'k' => {
277                // Primary keepalive
278                self.handle_keepalive(&data[1..]).await?;
279            }
280            _ => {
281                warn!("Unknown copy data message type: 0x{msg_type:02x}");
282            }
283        }
284
285        Ok(())
286    }
287
288    async fn handle_xlog_data(&mut self, data: &[u8]) -> Result<()> {
289        if data.len() < 24 {
290            return Err(anyhow!("XLogData message too short: {} bytes", data.len()));
291        }
292
293        // Parse XLogData header
294        let _start_lsn = u64::from_be_bytes([
295            data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
296        ]);
297        let end_lsn = u64::from_be_bytes([
298            data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15],
299        ]);
300        let _timestamp = i64::from_be_bytes([
301            data[16], data[17], data[18], data[19], data[20], data[21], data[22], data[23],
302        ]);
303
304        // Update read LSN
305        self.read_lsn = end_lsn;
306        self.replay_state
307            .read_lsn
308            .store(self.read_lsn, Ordering::Release);
309
310        // Decode the actual WAL message
311        let wal_data = &data[24..];
312
313        // Try to decode, but don't fail on partial messages
314        if !wal_data.is_empty() {
315            let msg_type = wal_data[0];
316            debug!(
317                "Attempting to decode WAL message type: {} ({}), data length: {}",
318                msg_type as char,
319                msg_type,
320                wal_data.len()
321            );
322        }
323
324        match self.decoder.decode_message(wal_data) {
325            Ok(Some(wal_msg)) => {
326                self.process_wal_message(wal_msg).await?;
327            }
328            Ok(None) => {
329                // No message or skipped message type
330            }
331            Err(e) => {
332                // Log but don't fail on decode errors - might be partial message
333                if !wal_data.is_empty() {
334                    debug!(
335                        "Failed to decode WAL message type {} ({}): {}, data length: {}",
336                        wal_data[0] as char,
337                        wal_data[0],
338                        e,
339                        wal_data.len()
340                    );
341                }
342                // We'll get the rest in the next message
343            }
344        }
345
346        // Send feedback periodically
347        if self.last_feedback_time.elapsed() > Duration::from_secs(5) {
348            self.send_feedback(false).await?;
349        }
350
351        Ok(())
352    }
353
354    async fn handle_keepalive(&mut self, data: &[u8]) -> Result<()> {
355        if data.len() < 17 {
356            return Err(anyhow!("Keepalive message too short"));
357        }
358
359        let wal_end = u64::from_be_bytes([
360            data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
361        ]);
362        let reply = data[16];
363
364        self.read_lsn = wal_end;
365        self.replay_state
366            .read_lsn
367            .store(self.read_lsn, Ordering::Release);
368
369        if reply == 1 {
370            self.send_feedback(true).await?;
371        }
372
373        Ok(())
374    }
375
376    async fn process_wal_message(&mut self, msg: WalMessage) -> Result<()> {
377        match msg {
378            WalMessage::Begin(_) => {
379                // Start a new transaction — track changes with their LSN
380                self.pending_transaction = Some(Vec::new());
381            }
382            WalMessage::Commit(tx_info) => {
383                // Commit the transaction — stamp all changes with the commit LSN
384                // for atomic checkpoint semantics
385                if let Some(changes) = self.pending_transaction.take() {
386                    for (change, _) in changes {
387                        self.dispatch_change(change, tx_info.commit_lsn).await;
388                    }
389                    debug!(
390                        "Committed transaction {} with LSN {:x}",
391                        tx_info.xid, tx_info.commit_lsn
392                    );
393                }
394            }
395            WalMessage::Relation(relation) => {
396                // Store relation mapping - use table name as-is for label (no uppercase)
397                // This ensures consistency with bootstrap which uses the actual table name case
398                let label = relation.name.clone();
399                self.relations.insert(
400                    relation.id,
401                    RelationMapping {
402                        table_name: relation.name.clone(),
403                        schema_name: relation.namespace.clone(),
404                        label,
405                    },
406                );
407
408                // Update decoder's relation info
409                // The decoder already handles this internally
410            }
411            WalMessage::Insert { relation_id, tuple } => {
412                if let Some(change) = self.convert_insert(relation_id, tuple).await? {
413                    if let Some(tx) = &mut self.pending_transaction {
414                        tx.push((change, self.read_lsn));
415                    } else {
416                        self.dispatch_change(change, self.read_lsn).await;
417                    }
418                }
419            }
420            WalMessage::Update {
421                relation_id,
422                old_tuple,
423                new_tuple,
424            } => {
425                if let Some(change) = self
426                    .convert_update(relation_id, old_tuple, new_tuple)
427                    .await?
428                {
429                    if let Some(tx) = &mut self.pending_transaction {
430                        tx.push((change, self.read_lsn));
431                    } else {
432                        self.dispatch_change(change, self.read_lsn).await;
433                    }
434                }
435            }
436            WalMessage::Delete {
437                relation_id,
438                old_tuple,
439            } => {
440                if let Some(change) = self.convert_delete(relation_id, old_tuple).await? {
441                    if let Some(tx) = &mut self.pending_transaction {
442                        tx.push((change, self.read_lsn));
443                    } else {
444                        self.dispatch_change(change, self.read_lsn).await;
445                    }
446                }
447            }
448            WalMessage::Truncate { relation_ids } => {
449                warn!("Truncate not yet implemented for relations: {relation_ids:?}");
450            }
451        }
452        Ok(())
453    }
454
455    async fn convert_insert(
456        &self,
457        relation_id: u32,
458        tuple: Vec<super::types::PostgresValue>,
459    ) -> Result<Option<SourceChange>> {
460        // Get relation info
461        let relation = self
462            .decoder
463            .get_relation(relation_id)
464            .ok_or_else(|| anyhow!("Unknown relation {relation_id}"))?;
465
466        let mapping = self
467            .relations
468            .get(&relation_id)
469            .ok_or_else(|| anyhow!("No mapping for relation {relation_id}"))?;
470
471        // Convert tuple to properties
472        let mut properties = drasi_core::models::ElementPropertyMap::new();
473        for (i, value) in tuple.iter().enumerate() {
474            if let Some(column) = relation.columns.get(i) {
475                let json_value = value.to_json();
476                if !json_value.is_null() {
477                    properties.insert(
478                        &column.name,
479                        drasi_lib::sources::manager::convert_json_to_element_value(&json_value),
480                    );
481                }
482            }
483        }
484
485        // Create element ID (use primary key if available, otherwise generate)
486        let element_id = self.generate_element_id(relation, &tuple).await?;
487
488        // Create the element
489        let element = Element::Node {
490            metadata: ElementMetadata {
491                reference: ElementReference::new(&self.source_id, &element_id),
492                labels: Arc::from([Arc::from(mapping.label.as_str())]),
493                effective_from: Utc::now().timestamp_millis() as u64,
494            },
495            properties,
496        };
497
498        Ok(Some(SourceChange::Insert { element }))
499    }
500
501    async fn convert_update(
502        &self,
503        relation_id: u32,
504        old_tuple: Option<Vec<super::types::PostgresValue>>,
505        new_tuple: Vec<super::types::PostgresValue>,
506    ) -> Result<Option<SourceChange>> {
507        let relation = self
508            .decoder
509            .get_relation(relation_id)
510            .ok_or_else(|| anyhow!("Unknown relation {relation_id}"))?;
511
512        let mapping = self
513            .relations
514            .get(&relation_id)
515            .ok_or_else(|| anyhow!("No mapping for relation {relation_id}"))?;
516
517        // Generate element ID (should be the same for both old and new tuples)
518        let element_id = self.generate_element_id(relation, &new_tuple).await?;
519
520        if old_tuple.is_none() {
521            warn!("UPDATE without old tuple for relation {relation_id}, preserving UPDATE");
522        }
523
524        // Create properties for after state
525        // Note: We allow UPDATE without old_tuple to avoid converting to INSERT.
526        let mut after_properties = drasi_core::models::ElementPropertyMap::new();
527
528        // Process new tuple (after state)
529        for (i, column) in relation.columns.iter().enumerate() {
530            if let Some(value) = new_tuple.get(i) {
531                let json_value = value.to_json();
532                if !json_value.is_null() {
533                    after_properties.insert(
534                        &column.name,
535                        drasi_lib::sources::manager::convert_json_to_element_value(&json_value),
536                    );
537                }
538            }
539        }
540
541        let after_element = Element::Node {
542            metadata: ElementMetadata {
543                reference: ElementReference::new(&self.source_id, &element_id),
544                labels: Arc::from([Arc::from(mapping.label.as_str())]),
545                effective_from: Utc::now().timestamp_millis() as u64,
546            },
547            properties: after_properties,
548        };
549
550        Ok(Some(SourceChange::Update {
551            element: after_element,
552        }))
553    }
554
555    async fn convert_delete(
556        &self,
557        relation_id: u32,
558        old_tuple: Vec<super::types::PostgresValue>,
559    ) -> Result<Option<SourceChange>> {
560        let relation = self
561            .decoder
562            .get_relation(relation_id)
563            .ok_or_else(|| anyhow!("Unknown relation {relation_id}"))?;
564
565        let mapping = self
566            .relations
567            .get(&relation_id)
568            .ok_or_else(|| anyhow!("No mapping for relation {relation_id}"))?;
569
570        let element_id = self.generate_element_id(relation, &old_tuple).await?;
571
572        Ok(Some(SourceChange::Delete {
573            metadata: ElementMetadata {
574                reference: ElementReference::new(&self.source_id, &element_id),
575                labels: Arc::from([Arc::from(mapping.label.as_str())]),
576                effective_from: Utc::now().timestamp_millis() as u64,
577            },
578        }))
579    }
580
581    /// Generate a stable element ID for a tuple based on primary key values.
582    ///
583    /// Priority order:
584    /// 1. User-configured key columns (from table_keys config)
585    /// 2. Automatically detected primary keys from PostgreSQL system catalogs
586    /// 3. UUID fallback if no keys are available
587    ///
588    /// Element ID format:
589    /// - Single key: Table name prefix with value (e.g., "stocks:AAPL")
590    /// - Composite key: Table name prefix with values joined (e.g., "portfolio:tenant1_user2")
591    /// - No key: Table name prefix with UUID (e.g., "orders:550e8400-e29b-41d4-a716-446655440000")
592    async fn generate_element_id(
593        &self,
594        relation: &super::types::RelationInfo,
595        tuple: &[super::types::PostgresValue],
596    ) -> Result<String> {
597        // Get the table name (use schema-qualified if not in public schema)
598        let table_name = if relation.namespace == "public" {
599            relation.name.clone()
600        } else {
601            format!("{}.{}", relation.namespace, relation.name)
602        };
603
604        // Get primary key columns for this table
605        let primary_keys = self.table_primary_keys.read().await;
606        let pk_columns = primary_keys.get(&table_name);
607
608        // Check configured table_keys first
609        let configured_keys = self
610            .config
611            .table_keys
612            .iter()
613            .find(|tk| tk.table == table_name)
614            .map(|tk| &tk.key_columns);
615
616        // Use configured keys if available, otherwise use detected primary keys
617        let key_columns = configured_keys.or(pk_columns);
618
619        if let Some(keys) = key_columns {
620            let mut key_parts = Vec::new();
621
622            for (i, column) in relation.columns.iter().enumerate() {
623                if keys.contains(&column.name) {
624                    if let Some(value) = tuple.get(i) {
625                        let json_val = value.to_json();
626                        if !json_val.is_null() {
627                            // Remove quotes from JSON string representation
628                            let val_str = json_val.to_string();
629                            let cleaned = val_str.trim_matches('"');
630                            key_parts.push(cleaned.to_string());
631                        }
632                    }
633                }
634            }
635
636            if !key_parts.is_empty() {
637                // Include table name as prefix to ensure uniqueness across tables
638                return Ok(format!("{}:{}", table_name, key_parts.join("_")));
639            }
640        }
641
642        // No primary key found or all key values are NULL
643        warn!("No primary key value found for table '{table_name}'. Consider adding 'table_keys' configuration.");
644        // Still include table name prefix for consistency
645        Ok(format!("{}:{}", table_name, uuid::Uuid::new_v4()))
646    }
647
648    async fn send_feedback(&mut self, reply_requested: bool) -> Result<()> {
649        if let Some(conn) = &mut self.connection {
650            // Use the confirmed source position (min LSN across all query
651            // position handles) for flush_lsn / apply_lsn.  This tells
652            // Postgres that WAL up to this LSN has been durably processed
653            // by all subscribers.  Using read_lsn here would advance the
654            // slot watermark past un-checkpointed query positions, causing
655            // PositionUnavailable on crash+restart.
656            let confirmed_lsn = match self.base.compute_confirmed_source_position().await {
657                Some(bytes) if bytes.len() == 8 => {
658                    let arr: [u8; 8] = bytes[..8].try_into().expect("length already checked");
659                    u64::from_be_bytes(arr)
660                }
661                Some(bytes) => {
662                    warn!(
663                        "[{}] Confirmed source position has unexpected length {} (expected 8); \
664                             not advancing flush_lsn",
665                        self.source_id,
666                        bytes.len()
667                    );
668                    0
669                }
670                None => 0, // No confirmed position yet — don't advance
671            };
672
673            // Apply the flush fence: during the subscription window after a
674            // replay restart, cap flush_lsn to prevent the slot's restart_lsn
675            // from advancing past positions that pending subscribers still need.
676            let fence = self.replay_state.effective_flush_fence();
677            let (effective_lsn, was_clamped) = if fence < u64::MAX && confirmed_lsn > fence {
678                (fence, true)
679            } else {
680                (confirmed_lsn, false)
681            };
682
683            let status = StandbyStatusUpdate {
684                write_lsn: self.read_lsn,
685                flush_lsn: effective_lsn,
686                apply_lsn: effective_lsn,
687                reply_requested,
688            };
689
690            conn.send_standby_status(status).await?;
691            self.last_feedback_time = std::time::Instant::now();
692
693            // Prune the sequence→position map up to the confirmed sequence
694            // now that feedback was successfully sent. Skip pruning when the
695            // fence clamped flush_lsn — Postgres hasn't actually acknowledged
696            // the full confirmed position, so we must retain map entries for
697            // accurate re-computation after the fence lifts.
698            if !was_clamped && effective_lsn > 0 {
699                if let Some(confirmed_seq) = self.base.compute_confirmed_position().await {
700                    self.base.prune_position_map(confirmed_seq).await;
701                }
702            }
703
704            trace!(
705                "[{}] Sent feedback: write_lsn={:x}, flush_lsn={:x}{}",
706                self.source_id,
707                self.read_lsn,
708                effective_lsn,
709                if was_clamped { " (fenced)" } else { "" }
710            );
711        }
712
713        Ok(())
714    }
715
716    /// Dispatch a single change event through the framework's `dispatch_event()`.
717    ///
718    /// Sets `source_position` to the 8-byte big-endian WAL LSN so the checkpoint
719    /// framework can persist the position for recovery/replay.
720    async fn dispatch_change(&self, change: SourceChange, lsn: u64) {
721        let mut profiling = drasi_lib::profiling::ProfilingMetadata::new();
722        profiling.source_send_ns = Some(drasi_lib::profiling::timestamp_ns());
723
724        let mut wrapper = SourceEventWrapper::with_profiling(
725            self.source_id.clone(),
726            SourceEvent::Change(change),
727            chrono::Utc::now(),
728            profiling,
729        );
730
731        // Attach the WAL LSN as opaque source_position bytes for checkpoint/recovery
732        wrapper.set_source_position(super::connection::lsn_to_position_bytes(lsn));
733
734        // Use dispatch_event() which stamps the monotonic sequence
735        if let Err(e) = self.base.dispatch_event(wrapper).await {
736            debug!(
737                "[{}] Failed to dispatch change (no subscribers): {}",
738                self.source_id, e
739            );
740        }
741    }
742
743    #[allow(dead_code)]
744    async fn check_stop_signal(&self) -> bool {
745        let status = self.status_handle.get_status().await;
746        status == ComponentStatus::Stopping || status == ComponentStatus::Stopped
747    }
748
749    async fn recover_connection(&mut self) -> Result<()> {
750        warn!("Attempting to recover connection");
751
752        // Close existing connection if any
753        if let Some(conn) = self.connection.take() {
754            let _ = conn.close().await;
755        }
756
757        // Wait a bit before reconnecting
758        sleep(Duration::from_secs(5)).await;
759
760        // Try to reconnect
761        self.connect_and_setup().await?;
762
763        info!("Connection recovered successfully");
764        Ok(())
765    }
766
767    async fn shutdown(&mut self) -> Result<()> {
768        info!("Shutting down replication stream");
769
770        // Send final feedback
771        let _ = self.send_feedback(false).await;
772
773        // Close connection
774        if let Some(conn) = self.connection.take() {
775            conn.close().await?;
776        }
777
778        Ok(())
779    }
780}
781
782#[cfg(test)]
783mod tests {
784    use chrono::Utc;
785    use drasi_core::models::validate_effective_from;
786
787    /// Validates that the timestamp pattern used in convert_insert/convert_update/convert_delete
788    /// produces a value in the millisecond range.
789    #[test]
790    fn effective_from_uses_milliseconds() {
791        let effective_from = Utc::now().timestamp_millis() as u64;
792        assert!(
793            validate_effective_from(effective_from).is_ok(),
794            "Postgres CDC effective_from ({effective_from}) should be in millisecond range"
795        );
796    }
797
798    /// Verifies that using nanoseconds would be caught by the validator.
799    #[test]
800    fn effective_from_rejects_nanoseconds_pattern() {
801        let bad_effective_from = Utc::now().timestamp_nanos_opt().unwrap() as u64;
802        assert!(
803            validate_effective_from(bad_effective_from).is_err(),
804            "Nanosecond timestamp ({bad_effective_from}) should be rejected"
805        );
806    }
807}