Skip to main content

laminar_connectors/postgres/cdc/
source.rs

1//! `PostgreSQL` CDC source connector implementation.
2//!
3//! Implements [`SourceConnector`] for streaming logical replication changes
4//! from `PostgreSQL` into `LaminarDB` as Arrow `RecordBatch`es.
5//!
6use arrow_array::RecordBatch;
7use arrow_schema::SchemaRef;
8use async_trait::async_trait;
9use bytes::Bytes;
10use std::collections::{BTreeMap, VecDeque};
11use std::sync::Arc;
12use tokio::sync::Notify;
13use tokio::sync::{OwnedSemaphorePermit, Semaphore};
14
15use crate::checkpoint::SourceCheckpoint;
16use crate::config::{ConnectorConfig, ConnectorState};
17use crate::connector::{
18    ConnectorTaskOwner, ConnectorTaskTracker, SourceBatch, SourceConnector, SourceConsistency,
19    SourceContract, SourcePosition, SourceStart, SourceTopology,
20};
21use crate::error::ConnectorError;
22
23use super::changelog::{
24    events_to_record_batch, old_tuple_json_encoded_len, old_tuple_to_json, plan_record_batch,
25    tuple_json_encoded_len, tuple_to_json, CdcOperation, ChangeEvent,
26};
27use super::config::PostgresCdcConfig;
28use super::decoder::{decode_message, OldTuple, WalMessage};
29use super::lsn::Lsn;
30use super::metrics::PostgresCdcMetrics;
31use super::postgres_io::{source_config_digest, PostgresCheckpointBinding};
32use super::schema::{cdc_envelope_schema, RelationCache, RelationInfo};
33
34#[cfg(not(test))]
35const PGWIRE_IN_FLIGHT_EVENTS: usize = 1;
36#[cfg(not(test))]
37const RAW_WAL_QUEUE_CAPACITY: usize = 4_096;
38const INITIAL_BOOTSTRAP_NOT_ADMITTED: &str = "[LDB-5060] PostgreSQL CDC initial startup is not admitted until a complete, certified snapshot-to-WAL handoff is implemented";
39const CHECKPOINT_CONNECTOR: &str = "postgres-cdc";
40const CHECKPOINT_VERSION: &str = "3";
41const SYSTEM_IDENTIFIER_METADATA: &str = "system_identifier";
42const TIMELINE_ID_METADATA: &str = "timeline_id";
43const DATABASE_OID_METADATA: &str = "database_oid";
44const PUBLICATION_OID_METADATA: &str = "publication_oid";
45const PUBLICATION_DEFINITION_METADATA: &str = "publication_definition_sha256";
46const SOURCE_CONFIG_METADATA: &str = "source_config_sha256";
47const SLOT_PLUGIN_METADATA: &str = "slot_plugin";
48const SLOT_TWO_PHASE_METADATA: &str = "slot_two_phase";
49const SLOT_FAILOVER_METADATA: &str = "slot_failover";
50
51/// `PostgreSQL` CDC source connector.
52///
53/// Streams row-level changes from `PostgreSQL` using logical replication
54/// (`pgoutput` plugin). Changes are emitted as Arrow `RecordBatch`es
55/// in the CDC envelope format.
56///
57/// # Envelope Schema
58///
59/// | Column   | Type          | Nullable | Description                              |
60/// |----------|---------------|----------|------------------------------------------|
61/// | `_table` | Utf8          | no       | Schema-qualified table name              |
62/// | `_op`    | Utf8          | no       | Operation: I, U, D                       |
63/// | `_lsn`   | UInt64        | no       | WAL position                             |
64/// | `_ts_ms` | Timestamp(ms) | no       | Commit timestamp                         |
65/// | `_before`| Utf8          | yes      | Available old identity/full-row JSON     |
66/// | `_after` | Utf8          | yes      | New row JSON (for I, U)                  |
67pub struct PostgresCdcSource {
68    /// Connector configuration.
69    config: PostgresCdcConfig,
70
71    /// Current lifecycle state.
72    state: ConnectorState,
73
74    /// Output schema (CDC envelope).
75    schema: SchemaRef,
76
77    /// Lock-free metrics.
78    metrics: Arc<PostgresCdcMetrics>,
79
80    /// Cached relation (table) schemas from Relation messages.
81    relation_cache: RelationCache,
82
83    /// Committed transactions awaiting `poll_batch()` in WAL order.
84    committed_transactions: VecDeque<CommittedTransaction>,
85
86    /// Number of decoded events retained across the current and committed transactions.
87    buffered_event_count: usize,
88
89    /// Variable-width bytes retained by decoded events across current and committed transactions.
90    /// Event container capacities are measured separately when enforcing the decoded-stage limit.
91    buffered_event_bytes: usize,
92
93    /// Current transaction state.
94    current_txn: Option<TransactionState>,
95
96    /// Confirmed flush LSN (last acknowledged position).
97    confirmed_flush_lsn: Lsn,
98
99    /// Write LSN (latest position received from server).
100    write_lsn: Lsn,
101
102    /// Polled LSN — tracks the latest position drained into a batch.
103    /// Decoupled from `confirmed_flush_lsn` so the PG replication slot
104    /// is only advanced when the pipeline actually checkpoints.
105    polled_lsn: Lsn,
106
107    /// Exact database, publication, slot, and filter identity bound to checkpoints.
108    checkpoint_binding: Option<PostgresCheckpointBinding>,
109
110    /// Pending WAL messages used only by deterministic decoder tests.
111    #[cfg(test)]
112    pending_messages: VecDeque<Vec<u8>>,
113
114    /// Notification handle signalled when WAL data arrives from the reader task.
115    data_ready: Arc<Notify>,
116
117    /// Channel receiver for WAL events from the background reader task.
118    wal_rx: Option<WalPayloadRx>,
119
120    /// Background WAL reader task handle.
121    reader_handle: Option<tokio::task::JoinHandle<()>>,
122
123    /// Shutdown signal for the background reader task.
124    reader_shutdown: Option<tokio::sync::watch::Sender<bool>>,
125
126    /// Sender for feeding confirmed flush LSN back to the reader task.
127    /// The reader uses this to call `update_applied_lsn` only for
128    /// durably-checkpointed positions (prevents at-least-once violation).
129    confirmed_lsn_tx: Option<tokio::sync::watch::Sender<u64>>,
130
131    /// One-item readiness lookahead retained across bounded polls.
132    pending_payloads: VecDeque<OwnedWalPayload>,
133
134    /// Weighted budget shared by the reader queue and payloads deferred between polls.
135    wal_byte_budget: Option<Arc<Semaphore>>,
136
137    /// Fatal reader error delivered out of band so a full WAL queue cannot hide it.
138    wal_terminal_error: Option<WalTerminalError>,
139
140    /// Admission authority and terminal observer for this connector generation.
141    task_owner: ConnectorTaskOwner,
142    task_tracker: ConnectorTaskTracker,
143}
144
145impl Drop for PostgresCdcSource {
146    fn drop(&mut self) {
147        if let Some(shutdown) = self.reader_shutdown.take() {
148            shutdown.send_replace(true);
149        }
150        if let Some(handle) = self.reader_handle.take() {
151            reap_postgres_reader(handle, &self.task_owner);
152        }
153    }
154}
155
156fn reap_postgres_reader(handle: tokio::task::JoinHandle<()>, task_owner: &ConnectorTaskOwner) {
157    let Some(reaper_guard) = task_owner.track() else {
158        tracing::warn!("PostgreSQL CDC task generation was sealed before reader reaping");
159        return;
160    };
161    let Ok(runtime) = tokio::runtime::Handle::try_current() else {
162        // The reader's own guard remains authoritative. Dropping its runtime
163        // destroys the future and therefore resolves the generation tracker.
164        drop(reaper_guard);
165        return;
166    };
167    drop(runtime.spawn(async move {
168        let _reaper_guard = reaper_guard;
169        if let Err(error) = handle.await {
170            tracing::debug!(%error, "PostgreSQL CDC retired reader task reaped");
171        }
172    }));
173}
174
175/// In-progress transaction state.
176#[derive(Debug)]
177struct TransactionState {
178    /// Final LSN of the transaction.
179    final_lsn: Lsn,
180    /// Commit timestamp in milliseconds.
181    commit_ts_ms: i64,
182    /// Change events accumulated in this transaction.
183    events: VecDeque<ChangeEvent>,
184}
185
186/// A transaction is resumable only after every one of its events has been emitted.
187#[derive(Debug)]
188struct CommittedTransaction {
189    end_lsn: Lsn,
190    events: VecDeque<ChangeEvent>,
191}
192
193/// Single-consumer async receiver for the WAL reader → `poll_batch` queue.
194type WalPayloadRx = crossfire::AsyncRx<crossfire::mpsc::Array<OwnedWalPayload>>;
195
196type WalPayloadTx = crossfire::MAsyncTx<crossfire::mpsc::Array<OwnedWalPayload>>;
197
198type WalTerminalError = Arc<std::sync::Mutex<Option<String>>>;
199
200/// WAL event payload sent from the background reader task to [`PostgresCdcSource::poll_batch`].
201enum WalPayload {
202    Begin {
203        final_lsn: u64,
204        commit_ts_us: i64,
205        xid: u32,
206    },
207    Commit {
208        end_lsn: u64,
209        commit_ts_us: i64,
210        lsn: u64,
211    },
212    XLogData {
213        wal_end: u64,
214        data: Bytes,
215    },
216    KeepAlive {
217        wal_end: u64,
218    },
219}
220
221struct OwnedWalPayload {
222    payload: WalPayload,
223    _byte_permit: OwnedSemaphorePermit,
224    wire_bytes: Option<pgwire_replication::WireBytesGuard>,
225}
226
227fn retained_wal_payload_bytes(payload: &WalPayload) -> usize {
228    let dynamic_bytes = match payload {
229        WalPayload::XLogData { data, .. } => data.len(),
230        WalPayload::Begin { .. } | WalPayload::Commit { .. } | WalPayload::KeepAlive { .. } => 0,
231    };
232    std::mem::size_of::<OwnedWalPayload>()
233        .saturating_add(dynamic_bytes)
234        .max(1)
235}
236
237fn logical_wal_payload_bytes(payload: &WalPayload) -> usize {
238    match payload {
239        WalPayload::Begin { .. } => 1 + 8 + 8 + 4,
240        WalPayload::Commit { .. } => 1 + 1 + 8 + 8 + 8,
241        WalPayload::XLogData { data, .. } => data.len(),
242        WalPayload::KeepAlive { .. } => 0,
243    }
244}
245
246#[cfg(test)]
247async fn send_wal_or_shutdown(
248    tx: &WalPayloadTx,
249    payload: WalPayload,
250    byte_budget: &Arc<Semaphore>,
251    max_payload_bytes: usize,
252    shutdown_rx: &mut tokio::sync::watch::Receiver<bool>,
253) -> Result<bool, String> {
254    send_wal_with_wire_guard(
255        tx,
256        payload,
257        None,
258        byte_budget,
259        max_payload_bytes,
260        shutdown_rx,
261    )
262    .await
263}
264
265async fn send_wal_with_wire_guard(
266    tx: &WalPayloadTx,
267    payload: WalPayload,
268    wire_bytes: Option<pgwire_replication::WireBytesGuard>,
269    byte_budget: &Arc<Semaphore>,
270    max_payload_bytes: usize,
271    shutdown_rx: &mut tokio::sync::watch::Receiver<bool>,
272) -> Result<bool, String> {
273    if *shutdown_rx.borrow() {
274        return Ok(false);
275    }
276
277    let retained_bytes = retained_wal_payload_bytes(&payload);
278    if retained_bytes > max_payload_bytes {
279        return Err(format!(
280            "PostgreSQL CDC WAL payload exceeds the hard raw buffer limit \
281             (retained bytes: {retained_bytes}/{max_payload_bytes})"
282        ));
283    }
284    let permits = u32::try_from(retained_bytes)
285        .map_err(|_| "PostgreSQL CDC raw WAL byte budget exceeds semaphore capacity".to_string())?;
286    let permit = tokio::select! {
287        biased;
288        _ = shutdown_rx.changed() => return Ok(false),
289        result = Arc::clone(byte_budget).acquire_many_owned(permits) => result.map_err(|_| {
290            "PostgreSQL CDC raw WAL byte budget closed unexpectedly".to_string()
291        })?,
292    };
293    let owned = OwnedWalPayload {
294        payload,
295        _byte_permit: permit,
296        wire_bytes,
297    };
298    tokio::select! {
299        biased;
300        _ = shutdown_rx.changed() => Ok(false),
301        result = tx.send(owned) => Ok(result.is_ok()),
302    }
303}
304
305fn publish_terminal_wal_error(error: &WalTerminalError, message: String, data_ready: &Notify) {
306    let mut slot = error
307        .lock()
308        .unwrap_or_else(std::sync::PoisonError::into_inner);
309    if slot.is_none() {
310        *slot = Some(message);
311    }
312    drop(slot);
313    data_ready.notify_one();
314}
315
316fn take_confirmed_lsn(
317    receiver: &mut tokio::sync::watch::Receiver<u64>,
318) -> Option<pgwire_replication::Lsn> {
319    let confirmed = *receiver.borrow_and_update();
320    (confirmed > 0).then(|| pgwire_replication::Lsn::from_u64(confirmed))
321}
322
323#[cfg(not(test))]
324async fn run_wal_reader(
325    mut client: pgwire_replication::ReplicationClient,
326    wal_tx: WalPayloadTx,
327    byte_budget: Arc<Semaphore>,
328    byte_limit: usize,
329    mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
330    mut confirmed_lsn_rx: tokio::sync::watch::Receiver<u64>,
331    terminal_error: WalTerminalError,
332    data_ready: Arc<Notify>,
333    _reader_guard: crate::connector::ConnectorTaskGuard,
334) {
335    'read: loop {
336        tokio::select! {
337            biased;
338            changed = shutdown_rx.changed() => {
339                if changed.is_err() || *shutdown_rx.borrow() {
340                    break 'read;
341                }
342            }
343            changed = confirmed_lsn_rx.changed() => {
344                if changed.is_err() {
345                    break 'read;
346                }
347                if let Some(confirmed) = take_confirmed_lsn(&mut confirmed_lsn_rx) {
348                    client.update_applied_lsn(confirmed);
349                }
350            }
351            event = client.recv() => {
352                match event {
353                    Ok(Some(event)) => {
354                        let payload = match event {
355                            pgwire_replication::ReplicationEvent::Begin {
356                                final_lsn,
357                                xid,
358                                commit_time_micros,
359                            } => Some((
360                                WalPayload::Begin {
361                                    final_lsn: final_lsn.as_u64(),
362                                    commit_ts_us: commit_time_micros,
363                                    xid,
364                                },
365                                None,
366                            )),
367                            pgwire_replication::ReplicationEvent::Commit {
368                                end_lsn,
369                                commit_time_micros,
370                                lsn,
371                            } => Some((
372                                WalPayload::Commit {
373                                    end_lsn: end_lsn.as_u64(),
374                                    commit_ts_us: commit_time_micros,
375                                    lsn: lsn.as_u64(),
376                                },
377                                None,
378                            )),
379                            pgwire_replication::ReplicationEvent::XLogData {
380                                wal_end,
381                                data,
382                                wire_bytes,
383                                ..
384                            } => Some((
385                                WalPayload::XLogData {
386                                    wal_end: wal_end.as_u64(),
387                                    data,
388                                },
389                                Some(wire_bytes),
390                            )),
391                            pgwire_replication::ReplicationEvent::KeepAlive {
392                                wal_end, ..
393                            } => Some((
394                                WalPayload::KeepAlive {
395                                    wal_end: wal_end.as_u64(),
396                                },
397                                None,
398                            )),
399                            pgwire_replication::ReplicationEvent::Message { .. } => {
400                                publish_terminal_wal_error(
401                                    &terminal_error,
402                                    "PostgreSQL emitted a logical decoding message even though replication was started with messages=false"
403                                        .to_string(),
404                                    &data_ready,
405                                );
406                                break 'read;
407                            }
408                            pgwire_replication::ReplicationEvent::StoppedAt { reached } => {
409                                publish_terminal_wal_error(
410                                    &terminal_error,
411                                    format!(
412                                        "PostgreSQL replication stopped unexpectedly at {reached}; no stop LSN was configured"
413                                    ),
414                                    &data_ready,
415                                );
416                                break 'read;
417                            }
418                        };
419                        if let Some((payload, wire_bytes)) = payload {
420                            match send_wal_with_wire_guard(
421                                &wal_tx,
422                                payload,
423                                wire_bytes,
424                                &byte_budget,
425                                byte_limit,
426                                &mut shutdown_rx,
427                            )
428                            .await
429                            {
430                                Ok(true) => data_ready.notify_one(),
431                                Ok(false) => break 'read,
432                                Err(message) => {
433                                    publish_terminal_wal_error(
434                                        &terminal_error,
435                                        message,
436                                        &data_ready,
437                                    );
438                                    break 'read;
439                                }
440                            }
441                        }
442                    }
443                    Ok(None) => {
444                        publish_terminal_wal_error(
445                            &terminal_error,
446                            "PostgreSQL replication stream ended unexpectedly".into(),
447                            &data_ready,
448                        );
449                        break 'read;
450                    }
451                    Err(error) => {
452                        publish_terminal_wal_error(
453                            &terminal_error,
454                            format!("PostgreSQL replication stream failed: {error}"),
455                            &data_ready,
456                        );
457                        break 'read;
458                    }
459                }
460            }
461        }
462    }
463    let _ = client.shutdown().await;
464}
465
466fn retained_event_bytes(event: &ChangeEvent) -> Result<usize, ConnectorError> {
467    planned_event_bytes(
468        event.table.capacity(),
469        event.before.as_ref().map(String::capacity),
470        event.after.as_ref().map(String::capacity),
471    )
472}
473
474fn planned_event_bytes(
475    table_bytes: usize,
476    before_bytes: Option<usize>,
477    after_bytes: Option<usize>,
478) -> Result<usize, ConnectorError> {
479    [
480        table_bytes,
481        before_bytes.unwrap_or(0),
482        after_bytes.unwrap_or(0),
483    ]
484    .into_iter()
485    .try_fold(0_usize, |total, bytes| {
486        total.checked_add(bytes).ok_or_else(|| {
487            ConnectorError::ReadError(
488                "PostgreSQL CDC decoded-event retained-byte size overflow".into(),
489            )
490        })
491    })
492}
493
494fn conservative_deque_growth_bytes(
495    len: usize,
496    capacity: usize,
497    element_size: usize,
498) -> Result<usize, ConnectorError> {
499    if len < capacity {
500        return Ok(0);
501    }
502    capacity.max(4).checked_mul(element_size).ok_or_else(|| {
503        ConnectorError::ReadError("PostgreSQL CDC container growth size overflow".into())
504    })
505}
506
507fn required_checkpoint_metadata<'a>(
508    checkpoint: &'a SourceCheckpoint,
509    key: &str,
510    context: &str,
511) -> Result<&'a str, ConnectorError> {
512    checkpoint.get_metadata(key).ok_or_else(|| {
513        ConnectorError::ConfigurationError(format!(
514            "PostgreSQL CDC {context} is missing required '{key}' metadata"
515        ))
516    })
517}
518
519fn parse_checkpoint_decimal<T>(
520    checkpoint: &SourceCheckpoint,
521    key: &str,
522    context: &str,
523) -> Result<T, ConnectorError>
524where
525    T: std::str::FromStr + ToString,
526    T::Err: std::fmt::Display,
527{
528    let value = required_checkpoint_metadata(checkpoint, key, context)?;
529    let parsed = value.parse::<T>().map_err(|error| {
530        ConnectorError::ConfigurationError(format!(
531            "PostgreSQL CDC {context} has invalid '{key}' metadata '{value}': {error}"
532        ))
533    })?;
534    if parsed.to_string() != value {
535        return Err(ConnectorError::ConfigurationError(format!(
536            "PostgreSQL CDC {context} has non-canonical '{key}' metadata '{value}'"
537        )));
538    }
539    Ok(parsed)
540}
541
542fn parse_checkpoint_bool(
543    checkpoint: &SourceCheckpoint,
544    key: &str,
545    context: &str,
546) -> Result<bool, ConnectorError> {
547    match required_checkpoint_metadata(checkpoint, key, context)? {
548        "true" => Ok(true),
549        "false" => Ok(false),
550        value => Err(ConnectorError::ConfigurationError(format!(
551            "PostgreSQL CDC {context} has invalid '{key}' metadata '{value}'"
552        ))),
553    }
554}
555
556fn parse_checkpoint_sha256(
557    checkpoint: &SourceCheckpoint,
558    key: &str,
559    context: &str,
560) -> Result<String, ConnectorError> {
561    let value = required_checkpoint_metadata(checkpoint, key, context)?;
562    if value.len() != 64
563        || !value
564            .bytes()
565            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
566    {
567        return Err(ConnectorError::ConfigurationError(format!(
568            "PostgreSQL CDC {context} has invalid '{key}' SHA-256 metadata"
569        )));
570    }
571    Ok(value.to_string())
572}
573
574fn checkpoint_binding(
575    checkpoint: &SourceCheckpoint,
576    context: &str,
577) -> Result<PostgresCheckpointBinding, ConnectorError> {
578    Ok(PostgresCheckpointBinding {
579        system_identifier: parse_checkpoint_decimal(
580            checkpoint,
581            SYSTEM_IDENTIFIER_METADATA,
582            context,
583        )?,
584        timeline_id: parse_checkpoint_decimal(checkpoint, TIMELINE_ID_METADATA, context)?,
585        database_oid: parse_checkpoint_decimal(checkpoint, DATABASE_OID_METADATA, context)?,
586        publication_oid: parse_checkpoint_decimal(checkpoint, PUBLICATION_OID_METADATA, context)?,
587        publication_definition_sha256: parse_checkpoint_sha256(
588            checkpoint,
589            PUBLICATION_DEFINITION_METADATA,
590            context,
591        )?,
592        source_config_sha256: parse_checkpoint_sha256(checkpoint, SOURCE_CONFIG_METADATA, context)?,
593        slot_plugin: required_checkpoint_metadata(checkpoint, SLOT_PLUGIN_METADATA, context)?
594            .to_string(),
595        slot_two_phase: parse_checkpoint_bool(checkpoint, SLOT_TWO_PHASE_METADATA, context)?,
596        slot_failover: parse_checkpoint_bool(checkpoint, SLOT_FAILOVER_METADATA, context)?,
597    })
598}
599
600fn validate_checkpoint_identity(
601    checkpoint: &SourceCheckpoint,
602    config: &PostgresCdcConfig,
603    context: &str,
604) -> Result<PostgresCheckpointBinding, ConnectorError> {
605    for (key, expected) in [
606        ("checkpoint_version", CHECKPOINT_VERSION),
607        ("connector", CHECKPOINT_CONNECTOR),
608        ("slot_name", config.slot_name.as_str()),
609        ("publication", config.publication.as_str()),
610        ("database", config.database.as_str()),
611    ] {
612        let actual = required_checkpoint_metadata(checkpoint, key, context)?;
613        if actual != expected {
614            return Err(ConnectorError::ConfigurationError(format!(
615                "PostgreSQL CDC {context} has '{key}' identity '{actual}', expected '{expected}'"
616            )));
617        }
618    }
619
620    let binding = checkpoint_binding(checkpoint, context)?;
621    let configured_digest = source_config_digest(config);
622    if binding.source_config_sha256 != configured_digest {
623        return Err(ConnectorError::ConfigurationError(format!(
624            "PostgreSQL CDC {context} source filter/configuration identity drifted from its checkpoint"
625        )));
626    }
627    Ok(binding)
628}
629
630fn write_checkpoint_binding(
631    checkpoint: &mut SourceCheckpoint,
632    binding: &PostgresCheckpointBinding,
633) {
634    checkpoint.set_metadata("connector", CHECKPOINT_CONNECTOR);
635    checkpoint.set_metadata("checkpoint_version", CHECKPOINT_VERSION);
636    checkpoint.set_metadata(
637        SYSTEM_IDENTIFIER_METADATA,
638        binding.system_identifier.to_string(),
639    );
640    checkpoint.set_metadata(TIMELINE_ID_METADATA, binding.timeline_id.to_string());
641    checkpoint.set_metadata(DATABASE_OID_METADATA, binding.database_oid.to_string());
642    checkpoint.set_metadata(
643        PUBLICATION_OID_METADATA,
644        binding.publication_oid.to_string(),
645    );
646    checkpoint.set_metadata(
647        PUBLICATION_DEFINITION_METADATA,
648        &binding.publication_definition_sha256,
649    );
650    checkpoint.set_metadata(SOURCE_CONFIG_METADATA, &binding.source_config_sha256);
651    checkpoint.set_metadata(SLOT_PLUGIN_METADATA, &binding.slot_plugin);
652    checkpoint.set_metadata(SLOT_TWO_PHASE_METADATA, binding.slot_two_phase.to_string());
653    checkpoint.set_metadata(SLOT_FAILOVER_METADATA, binding.slot_failover.to_string());
654}
655
656fn validate_live_binding(
657    checkpoint: &PostgresCheckpointBinding,
658    live: &PostgresCheckpointBinding,
659    context: &str,
660) -> Result<(), ConnectorError> {
661    if checkpoint != live {
662        return Err(ConnectorError::ConfigurationError(format!(
663            "PostgreSQL CDC {context} identity drifted from the live database, publication, or replication slot (checkpoint: {checkpoint:?}; live: {live:?})"
664        )));
665    }
666    Ok(())
667}
668
669impl PostgresCdcSource {
670    /// Creates a new `PostgreSQL` CDC source with the given configuration.
671    #[must_use]
672    pub fn new(mut config: PostgresCdcConfig, registry: Option<&prometheus::Registry>) -> Self {
673        config.normalize_table_filters();
674        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
675        Self {
676            config,
677            state: ConnectorState::Created,
678            schema: cdc_envelope_schema(),
679            metrics: Arc::new(PostgresCdcMetrics::new(registry)),
680            relation_cache: RelationCache::new(),
681            committed_transactions: VecDeque::new(),
682            buffered_event_count: 0,
683            buffered_event_bytes: 0,
684            current_txn: None,
685            confirmed_flush_lsn: Lsn::ZERO,
686            write_lsn: Lsn::ZERO,
687            polled_lsn: Lsn::ZERO,
688            checkpoint_binding: None,
689            #[cfg(test)]
690            pending_messages: VecDeque::new(),
691            data_ready: Arc::new(Notify::new()),
692            wal_rx: None,
693            reader_handle: None,
694            reader_shutdown: None,
695            confirmed_lsn_tx: None,
696            pending_payloads: VecDeque::new(),
697            wal_byte_budget: None,
698            wal_terminal_error: None,
699            task_owner,
700            task_tracker,
701        }
702    }
703
704    /// Creates a new source from a generic [`ConnectorConfig`].
705    ///
706    /// # Errors
707    ///
708    /// Returns `ConnectorError` if the configuration is invalid.
709    pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
710        let pg_config = PostgresCdcConfig::from_config(config)?;
711        Ok(Self::new(pg_config, None))
712    }
713
714    /// Returns a reference to the CDC configuration.
715    #[must_use]
716    pub fn config(&self) -> &PostgresCdcConfig {
717        &self.config
718    }
719
720    /// Returns the current confirmed flush LSN.
721    #[must_use]
722    pub fn confirmed_flush_lsn(&self) -> Lsn {
723        self.confirmed_flush_lsn
724    }
725
726    /// Returns the current write LSN.
727    #[must_use]
728    pub fn write_lsn(&self) -> Lsn {
729        self.write_lsn
730    }
731
732    /// Returns the current replication lag in bytes.
733    #[must_use]
734    pub fn replication_lag_bytes(&self) -> u64 {
735        self.write_lsn.diff(self.confirmed_flush_lsn)
736    }
737
738    /// Returns a reference to the relation cache.
739    #[must_use]
740    pub fn relation_cache(&self) -> &RelationCache {
741        &self.relation_cache
742    }
743
744    /// Returns the number of buffered events.
745    #[must_use]
746    pub fn buffered_events(&self) -> usize {
747        self.buffered_event_count
748    }
749
750    #[cfg(test)]
751    fn enqueue_wal_data(&mut self, data: Vec<u8>) {
752        self.pending_messages.push_back(data);
753    }
754
755    #[cfg(test)]
756    fn process_pending_messages(&mut self) -> Result<(), ConnectorError> {
757        while let Some(data) = self.pending_messages.pop_front() {
758            let result = decode_message(Bytes::from(data))
759                .map_err(|error| ConnectorError::ReadError(format!("pgoutput decode: {error}")))
760                .and_then(|message| self.process_wal_message(message));
761            if let Err(error) = result {
762                self.state = ConnectorState::Failed;
763                return Err(error);
764            }
765        }
766        Ok(())
767    }
768
769    /// Processes a single decoded WAL message.
770    fn process_wal_message(&mut self, msg: WalMessage) -> Result<(), ConnectorError> {
771        match msg {
772            WalMessage::Begin(begin) => {
773                if self.current_txn.is_some() {
774                    return Err(ConnectorError::ReadError(
775                        "PostgreSQL CDC received BEGIN before the current transaction committed"
776                            .into(),
777                    ));
778                }
779                self.current_txn = Some(TransactionState {
780                    final_lsn: begin.final_lsn,
781                    commit_ts_ms: begin.commit_ts_ms,
782                    events: VecDeque::new(),
783                });
784                self.reserve_committed_transaction_slot()?;
785            }
786            WalMessage::Commit(commit) => {
787                if let Err(error) = self.validate_commit_boundary(&commit) {
788                    self.state = ConnectorState::Failed;
789                    return Err(error);
790                }
791                self.reserve_committed_transaction_slot()?;
792                let txn = self.current_txn.take().ok_or_else(|| {
793                    ConnectorError::ReadError(
794                        "PostgreSQL CDC received COMMIT without an open transaction".into(),
795                    )
796                })?;
797                self.committed_transactions.push_back(CommittedTransaction {
798                    end_lsn: commit.end_lsn,
799                    events: txn.events,
800                });
801                self.write_lsn = self.write_lsn.max(commit.end_lsn);
802                self.metrics.record_transaction();
803                self.metrics
804                    .set_replication_lag_bytes(self.replication_lag_bytes());
805            }
806            WalMessage::Relation(rel) => {
807                let info = RelationInfo {
808                    relation_id: rel.relation_id,
809                    namespace: rel.namespace,
810                    name: rel.name,
811                    replica_identity: rel.replica_identity as char,
812                    columns: rel.columns,
813                };
814                self.admit_relation(info)?;
815            }
816            WalMessage::Insert(ins) => {
817                self.process_insert(ins.relation_id, &ins.new_tuple)?;
818            }
819            WalMessage::Update(upd) => {
820                self.process_update(upd.relation_id, upd.old_tuple.as_ref(), &upd.new_tuple)?;
821            }
822            WalMessage::Delete(del) => {
823                self.process_delete(del.relation_id, &del.old_tuple)?;
824            }
825            WalMessage::Truncate(trunc) => {
826                let table_names: Vec<String> = trunc
827                    .relation_ids
828                    .iter()
829                    .map(|id| {
830                        self.relation_cache
831                            .get(*id)
832                            .map_or_else(|| Ok(format!("oid:{id}")), RelationInfo::full_name)
833                    })
834                    .collect::<Result<_, ConnectorError>>()?;
835                return Err(ConnectorError::ReadError(format!(
836                    "TRUNCATE received on table(s): {}. \
837                     Cannot produce retraction events — restart the pipeline with a fresh snapshot.",
838                    table_names.join(", ")
839                )));
840            }
841            WalMessage::Origin(_) | WalMessage::Type(_) => {
842                // Origin and Type messages are noted but don't
843                // produce change events in the current implementation.
844            }
845        }
846        Ok(())
847    }
848
849    fn process_insert(
850        &mut self,
851        relation_id: u32,
852        new_tuple: &super::decoder::TupleData,
853    ) -> Result<(), ConnectorError> {
854        let (lsn, ts_ms) = self.require_current_txn_context()?;
855        let (table, after_len) = {
856            let relation = self.require_relation(relation_id)?;
857            let table = relation.full_name()?;
858
859            if !self.config.should_include_table(&table) {
860                return Ok(());
861            }
862
863            let after_len = tuple_json_encoded_len(new_tuple, relation)?;
864            (table, after_len)
865        };
866        let event_bytes = planned_event_bytes(table.capacity(), None, Some(after_len))?;
867        self.reserve_current_event_slot()?;
868        self.ensure_event_capacity(event_bytes)?;
869        let after_json = tuple_to_json(new_tuple, self.require_relation(relation_id)?, after_len)?;
870
871        let event = ChangeEvent {
872            table,
873            op: CdcOperation::Insert,
874            lsn,
875            ts_ms,
876            before: None,
877            after: Some(after_json),
878        };
879
880        self.push_event(event, event_bytes)?;
881        self.metrics.record_insert();
882        Ok(())
883    }
884
885    fn process_update(
886        &mut self,
887        relation_id: u32,
888        old_tuple: Option<&OldTuple>,
889        new_tuple: &super::decoder::TupleData,
890    ) -> Result<(), ConnectorError> {
891        let (lsn, ts_ms) = self.require_current_txn_context()?;
892        let (table, before_len, after_len) = {
893            let relation = self.require_relation(relation_id)?;
894            let table = relation.full_name()?;
895
896            if !self.config.should_include_table(&table) {
897                return Ok(());
898            }
899
900            let before_len = old_tuple
901                .map(|tuple| old_tuple_json_encoded_len(tuple, relation))
902                .transpose()?;
903            let after_len = tuple_json_encoded_len(new_tuple, relation)?;
904            (table, before_len, after_len)
905        };
906        let event_bytes = planned_event_bytes(table.capacity(), before_len, Some(after_len))?;
907        self.reserve_current_event_slot()?;
908        self.ensure_event_capacity(event_bytes)?;
909        let relation = self.require_relation(relation_id)?;
910        let before_json = old_tuple
911            .zip(before_len)
912            .map(|(tuple, length)| old_tuple_to_json(tuple, relation, length))
913            .transpose()?;
914        let after_json = tuple_to_json(new_tuple, relation, after_len)?;
915
916        let event = ChangeEvent {
917            table,
918            op: CdcOperation::Update,
919            lsn,
920            ts_ms,
921            before: before_json,
922            after: Some(after_json),
923        };
924
925        self.push_event(event, event_bytes)?;
926        self.metrics.record_update();
927        Ok(())
928    }
929
930    fn process_delete(
931        &mut self,
932        relation_id: u32,
933        old_tuple: &OldTuple,
934    ) -> Result<(), ConnectorError> {
935        let (lsn, ts_ms) = self.require_current_txn_context()?;
936        let (table, before_len) = {
937            let relation = self.require_relation(relation_id)?;
938            let table = relation.full_name()?;
939
940            if !self.config.should_include_table(&table) {
941                return Ok(());
942            }
943
944            let before_len = old_tuple_json_encoded_len(old_tuple, relation)?;
945            (table, before_len)
946        };
947        let event_bytes = planned_event_bytes(table.capacity(), Some(before_len), None)?;
948        self.reserve_current_event_slot()?;
949        self.ensure_event_capacity(event_bytes)?;
950        let before_json =
951            old_tuple_to_json(old_tuple, self.require_relation(relation_id)?, before_len)?;
952
953        let event = ChangeEvent {
954            table,
955            op: CdcOperation::Delete,
956            lsn,
957            ts_ms,
958            before: Some(before_json),
959            after: None,
960        };
961
962        self.push_event(event, event_bytes)?;
963        self.metrics.record_delete();
964        Ok(())
965    }
966
967    /// Looks up a relation by ID, returning a reference (no clone).
968    ///
969    /// The caller must extract all needed data (table name, JSON) from
970    /// the reference before calling `push_event` or other `&mut self`
971    /// methods (Rust's borrow rules require disjoint access).
972    fn require_relation(&self, relation_id: u32) -> Result<&RelationInfo, ConnectorError> {
973        self.relation_cache.get(relation_id).ok_or_else(|| {
974            ConnectorError::ReadError(format!(
975                "unknown relation ID {relation_id} (no Relation message received yet)"
976            ))
977        })
978    }
979
980    fn require_current_txn_context(&mut self) -> Result<(Lsn, i64), ConnectorError> {
981        if let Some(txn) = &self.current_txn {
982            return Ok((txn.final_lsn, txn.commit_ts_ms));
983        }
984        self.state = ConnectorState::Failed;
985        Err(ConnectorError::ReadError(
986            "PostgreSQL CDC received a row change outside a transaction".into(),
987        ))
988    }
989
990    fn validate_commit_boundary(
991        &self,
992        commit: &super::decoder::CommitMessage,
993    ) -> Result<(), ConnectorError> {
994        let transaction = self.current_txn.as_ref().ok_or_else(|| {
995            ConnectorError::ReadError(
996                "PostgreSQL CDC received COMMIT without an open transaction".into(),
997            )
998        })?;
999        if commit.commit_lsn != transaction.final_lsn {
1000            return Err(ConnectorError::ReadError(format!(
1001                "PostgreSQL CDC COMMIT LSN {} does not match BEGIN final LSN {}",
1002                commit.commit_lsn, transaction.final_lsn
1003            )));
1004        }
1005        if commit.commit_ts_ms != transaction.commit_ts_ms {
1006            return Err(ConnectorError::ReadError(format!(
1007                "PostgreSQL CDC COMMIT timestamp {} does not match BEGIN timestamp {}",
1008                commit.commit_ts_ms, transaction.commit_ts_ms
1009            )));
1010        }
1011        if commit.end_lsn < commit.commit_lsn {
1012            return Err(ConnectorError::ReadError(format!(
1013                "PostgreSQL CDC COMMIT end LSN {} is before commit LSN {}",
1014                commit.end_lsn, commit.commit_lsn
1015            )));
1016        }
1017        let last_resumable_lsn = self
1018            .committed_transactions
1019            .back()
1020            .map_or(self.polled_lsn, |transaction| {
1021                transaction.end_lsn.max(self.polled_lsn)
1022            });
1023        if commit.end_lsn < last_resumable_lsn {
1024            return Err(ConnectorError::ReadError(format!(
1025                "PostgreSQL CDC COMMIT end LSN {} is behind the last emitted or queued LSN {last_resumable_lsn}",
1026                commit.end_lsn
1027            )));
1028        }
1029        Ok(())
1030    }
1031
1032    fn event_container_retained_bytes(&self) -> Result<usize, ConnectorError> {
1033        let event_size = std::mem::size_of::<ChangeEvent>();
1034        let mut retained = self
1035            .committed_transactions
1036            .capacity()
1037            .checked_mul(std::mem::size_of::<CommittedTransaction>())
1038            .ok_or_else(|| {
1039                ConnectorError::ReadError(
1040                    "PostgreSQL CDC committed-transaction container size overflow".into(),
1041                )
1042            })?;
1043        if let Some(transaction) = &self.current_txn {
1044            retained = retained
1045                .checked_add(
1046                    transaction
1047                        .events
1048                        .capacity()
1049                        .checked_mul(event_size)
1050                        .ok_or_else(|| {
1051                            ConnectorError::ReadError(
1052                                "PostgreSQL CDC open-transaction container size overflow".into(),
1053                            )
1054                        })?,
1055                )
1056                .ok_or_else(|| {
1057                    ConnectorError::ReadError(
1058                        "PostgreSQL CDC event-container retained-byte overflow".into(),
1059                    )
1060                })?;
1061        }
1062        for transaction in &self.committed_transactions {
1063            retained = retained
1064                .checked_add(
1065                    transaction
1066                        .events
1067                        .capacity()
1068                        .checked_mul(event_size)
1069                        .ok_or_else(|| {
1070                            ConnectorError::ReadError(
1071                                "PostgreSQL CDC committed-event container size overflow".into(),
1072                            )
1073                        })?,
1074                )
1075                .ok_or_else(|| {
1076                    ConnectorError::ReadError(
1077                        "PostgreSQL CDC event-container retained-byte overflow".into(),
1078                    )
1079                })?;
1080        }
1081        Ok(retained)
1082    }
1083
1084    fn decoded_retained_bytes(&self) -> Result<usize, ConnectorError> {
1085        let container_bytes = self.event_container_retained_bytes()?;
1086        let relation_bytes = self.relation_cache.retained_bytes()?;
1087        self.buffered_event_bytes
1088            .checked_add(container_bytes)
1089            .and_then(|bytes| bytes.checked_add(relation_bytes))
1090            .ok_or_else(|| {
1091                ConnectorError::ReadError(
1092                    "PostgreSQL CDC decoded-stage retained-byte accounting overflow".into(),
1093                )
1094            })
1095    }
1096
1097    fn ensure_decoded_byte_limit(
1098        &mut self,
1099        additional_bytes: usize,
1100        context: &str,
1101    ) -> Result<usize, ConnectorError> {
1102        let retained_bytes = self
1103            .decoded_retained_bytes()
1104            .and_then(|bytes| {
1105                bytes.checked_add(additional_bytes).ok_or_else(|| {
1106                    ConnectorError::ReadError(
1107                        "PostgreSQL CDC decoded-stage retained-byte accounting overflow".into(),
1108                    )
1109                })
1110            })
1111            .inspect_err(|_error| {
1112                self.state = ConnectorState::Failed;
1113            })?;
1114        let max_bytes = self.config.decoded_event_bytes();
1115        if retained_bytes > max_bytes {
1116            self.state = ConnectorState::Failed;
1117            return Err(ConnectorError::ReadError(format!(
1118                "PostgreSQL CDC {context} exceeds the hard decoded-stage buffer limit (retained bytes: {retained_bytes}/{max_bytes})"
1119            )));
1120        }
1121        Ok(retained_bytes)
1122    }
1123
1124    fn reserve_current_event_slot(&mut self) -> Result<(), ConnectorError> {
1125        let Some(transaction) = self.current_txn.as_ref() else {
1126            self.state = ConnectorState::Failed;
1127            return Err(ConnectorError::ReadError(
1128                "PostgreSQL CDC received a row change outside a transaction".into(),
1129            ));
1130        };
1131        let old_capacity = transaction.events.capacity();
1132        let growth_bytes = conservative_deque_growth_bytes(
1133            transaction.events.len(),
1134            old_capacity,
1135            std::mem::size_of::<ChangeEvent>(),
1136        )?;
1137        self.ensure_decoded_byte_limit(growth_bytes, "event-container growth")?;
1138        let Some(transaction) = self.current_txn.as_mut() else {
1139            self.state = ConnectorState::Failed;
1140            return Err(ConnectorError::Internal(
1141                "PostgreSQL CDC open transaction disappeared during container preflight".into(),
1142            ));
1143        };
1144        if let Err(error) = transaction.events.try_reserve_exact(1) {
1145            self.state = ConnectorState::Failed;
1146            return Err(ConnectorError::ReadError(format!(
1147                "PostgreSQL CDC could not reserve decoded-event storage: {error}"
1148            )));
1149        }
1150        let Some(actual_growth) = transaction
1151            .events
1152            .capacity()
1153            .checked_sub(old_capacity)
1154            .and_then(|capacity| capacity.checked_mul(std::mem::size_of::<ChangeEvent>()))
1155        else {
1156            self.state = ConnectorState::Failed;
1157            return Err(ConnectorError::Internal(
1158                "PostgreSQL CDC event-container growth accounting failed".into(),
1159            ));
1160        };
1161        if actual_growth > growth_bytes {
1162            self.state = ConnectorState::Failed;
1163            return Err(ConnectorError::Internal(format!(
1164                "PostgreSQL CDC event-container growth exceeded its conservative preflight: actual={actual_growth}, planned={growth_bytes}"
1165            )));
1166        }
1167        self.ensure_decoded_byte_limit(0, "event-container growth")?;
1168        Ok(())
1169    }
1170
1171    fn reserve_committed_transaction_slot(&mut self) -> Result<(), ConnectorError> {
1172        if self.current_txn.is_none() {
1173            self.state = ConnectorState::Failed;
1174            return Err(ConnectorError::ReadError(
1175                "PostgreSQL CDC received COMMIT without an open transaction".into(),
1176            ));
1177        }
1178        self.committed_transactions
1179            .len()
1180            .checked_add(1)
1181            .ok_or_else(|| {
1182                self.state = ConnectorState::Failed;
1183                ConnectorError::ReadError(
1184                    "PostgreSQL CDC committed-transaction count overflow".into(),
1185                )
1186            })?;
1187        let old_capacity = self.committed_transactions.capacity();
1188        let growth_bytes = conservative_deque_growth_bytes(
1189            self.committed_transactions.len(),
1190            old_capacity,
1191            std::mem::size_of::<CommittedTransaction>(),
1192        )?;
1193        self.ensure_decoded_byte_limit(growth_bytes, "committed-transaction container growth")?;
1194        if let Err(error) = self.committed_transactions.try_reserve_exact(1) {
1195            self.state = ConnectorState::Failed;
1196            return Err(ConnectorError::ReadError(format!(
1197                "PostgreSQL CDC could not reserve committed-transaction storage: {error}"
1198            )));
1199        }
1200        let actual_growth = self
1201            .committed_transactions
1202            .capacity()
1203            .checked_sub(old_capacity)
1204            .and_then(|capacity| capacity.checked_mul(std::mem::size_of::<CommittedTransaction>()))
1205            .ok_or_else(|| {
1206                self.state = ConnectorState::Failed;
1207                ConnectorError::Internal(
1208                    "PostgreSQL CDC committed-transaction growth accounting failed".into(),
1209                )
1210            })?;
1211        if actual_growth > growth_bytes {
1212            self.state = ConnectorState::Failed;
1213            return Err(ConnectorError::Internal(format!(
1214                "PostgreSQL CDC committed-transaction growth exceeded its conservative preflight: actual={actual_growth}, planned={growth_bytes}"
1215            )));
1216        }
1217        self.ensure_decoded_byte_limit(0, "committed-transaction container growth")?;
1218        Ok(())
1219    }
1220
1221    fn admit_relation(&mut self, info: RelationInfo) -> Result<(), ConnectorError> {
1222        let existing_bytes = self
1223            .relation_cache
1224            .get(info.relation_id)
1225            .map(RelationInfo::variable_retained_bytes)
1226            .transpose()
1227            .inspect_err(|_error| {
1228                self.state = ConnectorState::Failed;
1229            })?;
1230        let new_relation = usize::from(existing_bytes.is_none());
1231        let existing_bytes = existing_bytes.unwrap_or(0);
1232        self.relation_cache
1233            .len()
1234            .checked_add(new_relation)
1235            .ok_or_else(|| {
1236                self.state = ConnectorState::Failed;
1237                ConnectorError::ReadError("PostgreSQL CDC relation-cache count overflow".into())
1238            })?;
1239        let incoming_bytes = info.variable_retained_bytes().inspect_err(|_error| {
1240            self.state = ConnectorState::Failed;
1241        })?;
1242        let retained_growth = incoming_bytes.saturating_sub(existing_bytes);
1243        let growth_bytes = self
1244            .relation_cache
1245            .reservation_growth_bytes(info.relation_id)
1246            .inspect_err(|_error| {
1247                self.state = ConnectorState::Failed;
1248            })?;
1249        let admission_bytes = retained_growth.checked_add(growth_bytes).ok_or_else(|| {
1250            self.state = ConnectorState::Failed;
1251            ConnectorError::ReadError(
1252                "PostgreSQL CDC relation-cache admission size overflow".into(),
1253            )
1254        })?;
1255        self.ensure_decoded_byte_limit(admission_bytes, "relation-cache admission")?;
1256        let old_cache_bytes = self.relation_cache.retained_bytes()?;
1257        self.relation_cache
1258            .try_reserve_for(info.relation_id)
1259            .inspect_err(|_error| {
1260                self.state = ConnectorState::Failed;
1261            })?;
1262        let actual_growth = self
1263            .relation_cache
1264            .retained_bytes()?
1265            .checked_sub(old_cache_bytes)
1266            .ok_or_else(|| {
1267                self.state = ConnectorState::Failed;
1268                ConnectorError::Internal(
1269                    "PostgreSQL CDC relation-cache growth accounting failed".into(),
1270                )
1271            })?;
1272        if actual_growth > growth_bytes {
1273            self.state = ConnectorState::Failed;
1274            return Err(ConnectorError::Internal(format!(
1275                "PostgreSQL CDC relation-cache growth exceeded its conservative preflight: actual={actual_growth}, planned={growth_bytes}"
1276            )));
1277        }
1278        self.ensure_decoded_byte_limit(retained_growth, "relation-cache admission")?;
1279        self.relation_cache.insert(info).inspect_err(|_error| {
1280            self.state = ConnectorState::Failed;
1281        })?;
1282        self.ensure_decoded_byte_limit(0, "relation-cache retention")?;
1283        Ok(())
1284    }
1285
1286    fn ensure_event_capacity(&mut self, event_bytes: usize) -> Result<(), ConnectorError> {
1287        if self.current_txn.is_none() {
1288            self.state = ConnectorState::Failed;
1289            return Err(ConnectorError::ReadError(
1290                "PostgreSQL CDC received a row change outside a transaction".into(),
1291            ));
1292        }
1293
1294        self.buffered_event_count.checked_add(1).ok_or_else(|| {
1295            self.state = ConnectorState::Failed;
1296            ConnectorError::ReadError(
1297                "PostgreSQL CDC decoded-event count accounting overflow".into(),
1298            )
1299        })?;
1300        self.ensure_decoded_byte_limit(event_bytes, "transaction")?;
1301        Ok(())
1302    }
1303
1304    fn push_event(
1305        &mut self,
1306        event: ChangeEvent,
1307        preflight_bytes: usize,
1308    ) -> Result<(), ConnectorError> {
1309        let event_bytes = retained_event_bytes(&event)?;
1310        if event_bytes > preflight_bytes {
1311            self.state = ConnectorState::Failed;
1312            return Err(ConnectorError::Internal(format!(
1313                "PostgreSQL CDC decoded event exceeded its retained-byte preflight: actual={event_bytes}, planned={preflight_bytes}"
1314            )));
1315        }
1316        self.ensure_event_capacity(event_bytes)?;
1317        let next_event_count = self.buffered_event_count.checked_add(1).ok_or_else(|| {
1318            self.state = ConnectorState::Failed;
1319            ConnectorError::Internal(
1320                "PostgreSQL CDC preflighted event-count accounting overflow".into(),
1321            )
1322        })?;
1323        let next_event_bytes = self
1324            .buffered_event_bytes
1325            .checked_add(event_bytes)
1326            .ok_or_else(|| {
1327                self.state = ConnectorState::Failed;
1328                ConnectorError::Internal(
1329                    "PostgreSQL CDC preflighted retained-byte accounting overflow".into(),
1330                )
1331            })?;
1332
1333        let Some(txn) = self.current_txn.as_mut() else {
1334            self.state = ConnectorState::Failed;
1335            return Err(ConnectorError::Internal(
1336                "PostgreSQL CDC open transaction disappeared after event preflight".into(),
1337            ));
1338        };
1339        txn.events.push_back(event);
1340        self.buffered_event_count = next_event_count;
1341        self.buffered_event_bytes = next_event_bytes;
1342        Ok(())
1343    }
1344
1345    /// Processes a [`WalPayload`] received from the background reader task.
1346    fn process_wal_payload(&mut self, payload: WalPayload) -> Result<(), ConnectorError> {
1347        use super::decoder::pg_timestamp_to_unix_ms;
1348
1349        match payload {
1350            WalPayload::Begin {
1351                final_lsn,
1352                commit_ts_us,
1353                xid,
1354            } => {
1355                let begin = super::decoder::BeginMessage {
1356                    final_lsn: Lsn::new(final_lsn),
1357                    commit_ts_ms: pg_timestamp_to_unix_ms(commit_ts_us).map_err(|error| {
1358                        ConnectorError::ReadError(format!(
1359                            "pgoutput BEGIN timestamp decode: {error}"
1360                        ))
1361                    })?,
1362                    xid,
1363                };
1364                self.process_wal_message(WalMessage::Begin(begin))
1365            }
1366            WalPayload::Commit {
1367                end_lsn,
1368                commit_ts_us,
1369                lsn,
1370            } => {
1371                let commit = super::decoder::CommitMessage {
1372                    flags: 0,
1373                    commit_lsn: Lsn::new(lsn),
1374                    end_lsn: Lsn::new(end_lsn),
1375                    commit_ts_ms: pg_timestamp_to_unix_ms(commit_ts_us).map_err(|error| {
1376                        ConnectorError::ReadError(format!(
1377                            "pgoutput COMMIT timestamp decode: {error}"
1378                        ))
1379                    })?,
1380                };
1381                self.process_wal_message(WalMessage::Commit(commit))
1382            }
1383            WalPayload::XLogData { wal_end, data } => {
1384                let msg = decode_message(data)
1385                    .map_err(|e| ConnectorError::ReadError(format!("pgoutput decode: {e}")))?;
1386                self.process_wal_message(msg)?;
1387                self.write_lsn = self.write_lsn.max(Lsn::new(wal_end));
1388                Ok(())
1389            }
1390            WalPayload::KeepAlive { wal_end } => {
1391                self.write_lsn = self.write_lsn.max(Lsn::new(wal_end));
1392                Ok(())
1393            }
1394        }
1395    }
1396
1397    fn process_owned_wal_payload(
1398        &mut self,
1399        payload: OwnedWalPayload,
1400    ) -> Result<(), ConnectorError> {
1401        let received_bytes = u64::try_from(logical_wal_payload_bytes(&payload.payload))
1402            .map_err(|_| ConnectorError::Internal("PostgreSQL CDC byte metric overflow".into()))?;
1403        self.metrics.record_bytes(received_bytes);
1404        let OwnedWalPayload {
1405            payload,
1406            _byte_permit,
1407            wire_bytes,
1408        } = payload;
1409        let result = self.process_wal_payload(payload);
1410        drop(wire_bytes);
1411        result
1412    }
1413
1414    fn fail_on_terminal_wal_error(&mut self) -> Result<(), ConnectorError> {
1415        let message = self.wal_terminal_error.as_ref().and_then(|error| {
1416            error
1417                .lock()
1418                .unwrap_or_else(std::sync::PoisonError::into_inner)
1419                .take()
1420        });
1421        if let Some(message) = message {
1422            self.state = ConnectorState::Failed;
1423            return Err(ConnectorError::ReadError(message));
1424        }
1425        Ok(())
1426    }
1427
1428    /// Drains committed transactions without exposing a cursor inside a transaction.
1429    ///
1430    /// `max` is a batching target, not permission to split a `PostgreSQL` transaction. Logical
1431    /// replication can resume only at a WAL position, so a checkpoint between two fragments of
1432    /// one transaction would restore before rows already included in the checkpoint. When the
1433    /// first queued transaction is larger than `max`, emit it whole; the configured hard event
1434    /// and byte limits remain the memory bound.
1435    fn drain_events(&mut self, max: usize) -> Result<Option<RecordBatch>, ConnectorError> {
1436        if self.committed_transactions.is_empty() || max == 0 {
1437            return Ok(None);
1438        }
1439
1440        let mut selected_transactions = 0_usize;
1441        let mut selected_events = 0_usize;
1442        let mut resumable_lsn = self.polled_lsn;
1443        for transaction in &self.committed_transactions {
1444            let candidate_events = selected_events
1445                .checked_add(transaction.events.len())
1446                .ok_or_else(|| {
1447                    self.state = ConnectorState::Failed;
1448                    ConnectorError::Internal(
1449                        "PostgreSQL CDC drain event-count accounting overflow".into(),
1450                    )
1451                })?;
1452            // Once the row target is full, still absorb immediately-following
1453            // filtered transactions. They emit no rows, so advancing across
1454            // them preserves WAL order without splitting visible output or
1455            // forcing a separate empty poll just to move the durable cursor.
1456            if selected_events != 0 && !transaction.events.is_empty() && candidate_events > max {
1457                break;
1458            }
1459            selected_events = candidate_events;
1460            selected_transactions = selected_transactions.checked_add(1).ok_or_else(|| {
1461                self.state = ConnectorState::Failed;
1462                ConnectorError::Internal(
1463                    "PostgreSQL CDC drain transaction-count accounting overflow".into(),
1464                )
1465            })?;
1466            resumable_lsn = transaction.end_lsn;
1467        }
1468
1469        if selected_events == 0 {
1470            // All leading transactions were filtered to zero output rows.
1471            for _ in 0..selected_transactions {
1472                self.committed_transactions.pop_front();
1473            }
1474            if self.committed_transactions.is_empty() {
1475                self.committed_transactions = VecDeque::new();
1476            }
1477            self.polled_lsn = resumable_lsn;
1478            return Ok(None);
1479        }
1480
1481        let drained_count = selected_events;
1482        let selected = self
1483            .committed_transactions
1484            .iter()
1485            .take(selected_transactions);
1486        let drained_bytes = match selected
1487            .clone()
1488            .flat_map(|transaction| transaction.events.iter())
1489            .try_fold(0_usize, |bytes, event| {
1490                bytes
1491                    .checked_add(retained_event_bytes(event)?)
1492                    .ok_or_else(|| {
1493                        ConnectorError::Internal(
1494                            "PostgreSQL CDC drained-event retained-byte accounting overflow".into(),
1495                        )
1496                    })
1497            }) {
1498            Ok(bytes) => bytes,
1499            Err(error) => {
1500                self.state = ConnectorState::Failed;
1501                return Err(error);
1502            }
1503        };
1504        if drained_count > self.buffered_event_count || drained_bytes > self.buffered_event_bytes {
1505            self.state = ConnectorState::Failed;
1506            return Err(ConnectorError::Internal(
1507                "PostgreSQL CDC retained-buffer accounting invariant failed".into(),
1508            ));
1509        }
1510
1511        let plan =
1512            match plan_record_batch(selected.flat_map(|transaction| transaction.events.iter())) {
1513                Ok(plan) => plan,
1514                Err(error) => {
1515                    self.state = ConnectorState::Failed;
1516                    return Err(error);
1517                }
1518            };
1519        let arrow_byte_limit = self.config.arrow_build_bytes();
1520        let minimum_extraction_bytes = selected_transactions
1521            .checked_mul(std::mem::size_of::<VecDeque<ChangeEvent>>())
1522            .ok_or_else(|| {
1523                self.state = ConnectorState::Failed;
1524                ConnectorError::ReadError(
1525                    "PostgreSQL CDC Arrow extraction-container size overflow".into(),
1526                )
1527            })?;
1528        let minimum_arrow_bytes = plan
1529            .retained_bytes
1530            .checked_add(minimum_extraction_bytes)
1531            .ok_or_else(|| {
1532                self.state = ConnectorState::Failed;
1533                ConnectorError::ReadError(
1534                    "PostgreSQL CDC Arrow build retained-byte accounting overflow".into(),
1535                )
1536            })?;
1537        if minimum_arrow_bytes > arrow_byte_limit {
1538            self.state = ConnectorState::Failed;
1539            return Err(ConnectorError::ReadError(format!(
1540                "PostgreSQL CDC Arrow batch exceeds the hard build-buffer limit (retained bytes: {minimum_arrow_bytes}/{arrow_byte_limit})"
1541            )));
1542        }
1543
1544        // Move each transaction's existing event deque as a unit. This avoids allocating a second
1545        // `ChangeEvent` container while the decoded-stage container is still resident.
1546        let mut event_groups = Vec::new();
1547        if let Err(error) = event_groups.try_reserve_exact(selected_transactions) {
1548            self.state = ConnectorState::Failed;
1549            return Err(ConnectorError::ReadError(format!(
1550                "PostgreSQL CDC could not reserve Arrow extraction storage: {error}"
1551            )));
1552        }
1553        let extraction_capacity = event_groups.capacity();
1554        let extraction_bytes = extraction_capacity
1555            .checked_mul(std::mem::size_of::<VecDeque<ChangeEvent>>())
1556            .ok_or_else(|| {
1557                self.state = ConnectorState::Failed;
1558                ConnectorError::ReadError(
1559                    "PostgreSQL CDC Arrow extraction-container size overflow".into(),
1560                )
1561            })?;
1562        let planned_arrow_bytes = plan
1563            .retained_bytes
1564            .checked_add(extraction_bytes)
1565            .ok_or_else(|| {
1566                self.state = ConnectorState::Failed;
1567                ConnectorError::ReadError(
1568                    "PostgreSQL CDC Arrow build retained-byte accounting overflow".into(),
1569                )
1570            })?;
1571        if planned_arrow_bytes > arrow_byte_limit {
1572            self.state = ConnectorState::Failed;
1573            return Err(ConnectorError::ReadError(format!(
1574                "PostgreSQL CDC Arrow batch exceeds the hard build-buffer limit (retained bytes: {planned_arrow_bytes}/{arrow_byte_limit})"
1575            )));
1576        }
1577
1578        for _ in 0..selected_transactions {
1579            let Some(mut transaction) = self.committed_transactions.pop_front() else {
1580                self.state = ConnectorState::Failed;
1581                return Err(ConnectorError::Internal(
1582                    "PostgreSQL CDC committed transaction disappeared after drain preflight".into(),
1583                ));
1584            };
1585            event_groups.push(std::mem::take(&mut transaction.events));
1586        }
1587        let extracted_events = match event_groups.iter().try_fold(0_usize, |count, events| {
1588            count.checked_add(events.len()).ok_or_else(|| {
1589                ConnectorError::Internal(
1590                    "PostgreSQL CDC Arrow extraction row-count overflow".into(),
1591                )
1592            })
1593        }) {
1594            Ok(count) => count,
1595            Err(error) => {
1596                self.state = ConnectorState::Failed;
1597                return Err(error);
1598            }
1599        };
1600        if extracted_events != selected_events || event_groups.capacity() != extraction_capacity {
1601            self.state = ConnectorState::Failed;
1602            return Err(ConnectorError::Internal(
1603                "PostgreSQL CDC Arrow extraction changed after capacity preflight".into(),
1604            ));
1605        }
1606        if self.committed_transactions.is_empty() {
1607            self.committed_transactions = VecDeque::new();
1608        }
1609
1610        let batch = match events_to_record_batch(event_groups.into_iter().flatten(), &plan) {
1611            Ok(batch) => batch,
1612            Err(error) => {
1613                self.buffered_event_count -= drained_count;
1614                self.buffered_event_bytes -= drained_bytes;
1615                self.state = ConnectorState::Failed;
1616                return Err(error);
1617            }
1618        };
1619
1620        self.buffered_event_count -= drained_count;
1621        self.buffered_event_bytes -= drained_bytes;
1622        self.polled_lsn = resumable_lsn;
1623        self.metrics.record_batch();
1624        Ok(Some(batch))
1625    }
1626}
1627
1628#[async_trait]
1629impl SourceConnector for PostgresCdcSource {
1630    fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
1631        Some(self.task_tracker.clone())
1632    }
1633
1634    fn recovery_identity_options(
1635        &self,
1636        config: &ConnectorConfig,
1637    ) -> Result<Option<BTreeMap<String, String>>, ConnectorError> {
1638        let mut parsed = if config.properties().is_empty() {
1639            self.config.clone()
1640        } else {
1641            PostgresCdcConfig::from_config(config)?
1642        };
1643        parsed.normalize_table_filters();
1644        parsed.validate()?;
1645
1646        Ok(Some(BTreeMap::from([
1647            ("database".into(), parsed.database),
1648            ("publication".into(), parsed.publication),
1649            ("slot.name".into(), parsed.slot_name),
1650            ("table.exclude".into(), parsed.table_exclude.join(",")),
1651            ("table.include".into(), parsed.table_include.join(",")),
1652            ("wire.protocol".into(), "pgoutput-v1".into()),
1653        ])))
1654    }
1655
1656    async fn start(&mut self, request: SourceStart) -> Result<(), ConnectorError> {
1657        if self.state != ConnectorState::Created {
1658            return Err(ConnectorError::InvalidState {
1659                expected: ConnectorState::Created.to_string(),
1660                actual: self.state.to_string(),
1661            });
1662        }
1663        let (config, position, _) = request.into_parts();
1664
1665        // Parse all configuration and validate the exact engine cursor before
1666        // changing lifecycle state or opening either the control or replication
1667        // connection. This keeps startup atomic from the connector's perspective.
1668        let mut parsed_config = if config.properties().is_empty() {
1669            self.config.clone()
1670        } else {
1671            PostgresCdcConfig::from_config(&config)?
1672        };
1673        parsed_config.normalize_table_filters();
1674        parsed_config.validate()?;
1675
1676        let (start_lsn, expected_binding) = match position {
1677            SourcePosition::Initial => {
1678                return Err(ConnectorError::ConfigurationError(
1679                    INITIAL_BOOTSTRAP_NOT_ADMITTED.into(),
1680                ));
1681            }
1682            SourcePosition::Resume {
1683                attempt,
1684                checkpoint,
1685            } => {
1686                let context = format!("checkpoint {attempt:?}");
1687                let binding = validate_checkpoint_identity(&checkpoint, &parsed_config, &context)?;
1688                let lsn_str = checkpoint.get_offset("lsn").ok_or_else(|| {
1689                    ConnectorError::ConfigurationError(format!(
1690                        "PostgreSQL CDC checkpoint {attempt:?} is missing required 'lsn' offset"
1691                    ))
1692                })?;
1693                let lsn = lsn_str.parse::<Lsn>().map_err(|e| {
1694                    ConnectorError::ConfigurationError(format!(
1695                        "invalid LSN '{lsn_str}' in PostgreSQL CDC checkpoint {attempt:?}: {e}"
1696                    ))
1697                })?;
1698                (lsn, binding)
1699            }
1700        };
1701
1702        #[cfg(not(test))]
1703        {
1704            use super::postgres_io;
1705
1706            // 1. Connect control-plane for slot management
1707            let control_driver_guard = self.task_owner.track().ok_or_else(|| {
1708                ConnectorError::Internal(
1709                    "PostgreSQL CDC connector generation is already retired".into(),
1710                )
1711            })?;
1712            let control = postgres_io::connect(&parsed_config, control_driver_guard).await?;
1713
1714            // 2. Inspect the exact durable slot. Resume is deliberately
1715            // read-only: a replacement slot starts at a different history and
1716            // cannot satisfy the engine checkpoint.
1717            let slot_inspection = postgres_io::inspect_replication_slot(
1718                control.client(),
1719                &parsed_config.slot_name,
1720                "pgoutput",
1721                &parsed_config.database,
1722                &parsed_config.publication,
1723                expected_binding.source_config_sha256.clone(),
1724            )
1725            .await;
1726            control.close().await;
1727            let slot_lsn = slot_inspection?;
1728
1729            let Some(slot) = slot_lsn.as_ref() else {
1730                return Err(ConnectorError::ConfigurationError(format!(
1731                    "cannot resume PostgreSQL CDC slot '{}': the exact durable slot is missing",
1732                    parsed_config.slot_name
1733                )));
1734            };
1735            validate_live_binding(&expected_binding, &slot.binding, "resume checkpoint")?;
1736            let Some(resume_slot_lsn) = slot.confirmed_flush_lsn.as_ref() else {
1737                return Err(ConnectorError::ConfigurationError(format!(
1738                    "cannot resume PostgreSQL CDC slot '{}': the slot has no retained durable position",
1739                    parsed_config.slot_name
1740                )));
1741            };
1742            if resume_slot_lsn.as_u64() > start_lsn.as_u64() {
1743                return Err(ConnectorError::ConfigurationError(format!(
1744                    "cannot resume PostgreSQL CDC checkpoint at {}: slot '{}' has already advanced to {}; required WAL may have been reclaimed",
1745                    start_lsn, parsed_config.slot_name, resume_slot_lsn
1746                )));
1747            }
1748
1749            // 3. Build pgwire-replication config and start WAL streaming
1750            let mut repl_config = postgres_io::build_replication_config(&parsed_config);
1751            repl_config.buffer_events = PGWIRE_IN_FLIGHT_EVENTS;
1752            // If we resolved a slot LSN, override start_lsn so we resume correctly
1753            if start_lsn != Lsn::ZERO {
1754                repl_config.start_lsn = pgwire_replication::Lsn::from_u64(start_lsn.as_u64());
1755            }
1756            repl_config.expected_recovery_identity =
1757                Some(pgwire_replication::ExpectedRecoveryIdentity {
1758                    system_identifier: expected_binding.system_identifier,
1759                    timeline_id: expected_binding.timeline_id,
1760                });
1761
1762            let replication_worker_guard = self.task_owner.track().ok_or_else(|| {
1763                ConnectorError::Internal(
1764                    "PostgreSQL CDC connector generation is already retired".into(),
1765                )
1766            })?;
1767            let repl_client = match tokio::time::timeout(
1768                postgres_io::CONNECT_TIMEOUT,
1769                pgwire_replication::ReplicationClient::connect_with_worker_lifetime(
1770                    repl_config,
1771                    replication_worker_guard,
1772                ),
1773            )
1774            .await
1775            {
1776                Ok(Ok(client)) => client,
1777                Ok(Err(error)) => {
1778                    return Err(ConnectorError::ConnectionFailed(format!(
1779                        "pgwire-replication connect: {error}"
1780                    )));
1781                }
1782                Err(_) => {
1783                    return Err(ConnectorError::ConnectionFailed(
1784                        "pgwire-replication connect timed out after 10 seconds".into(),
1785                    ));
1786                }
1787            };
1788
1789            // Spawn background reader task for event-driven wake-up.
1790            let raw_wal_byte_limit = parsed_config.raw_wal_bytes();
1791            let (wal_tx, wal_rx) =
1792                crossfire::mpsc::bounded_async::<OwnedWalPayload>(RAW_WAL_QUEUE_CAPACITY);
1793            // This is Laminar's raw-WAL queue ceiling. pgwire independently applies the same
1794            // aggregate ceiling while reading and handing off backend frames; its reservation is
1795            // retained until this queue's permit is acquired, leaving no unaccounted ownership gap.
1796            let wal_byte_budget = Arc::new(Semaphore::new(raw_wal_byte_limit));
1797            let reader_byte_budget = Arc::clone(&wal_byte_budget);
1798            let terminal_error: WalTerminalError = Arc::new(std::sync::Mutex::new(None));
1799            let reader_terminal_error = Arc::clone(&terminal_error);
1800            let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
1801            let (confirmed_lsn_tx, confirmed_lsn_rx) =
1802                tokio::sync::watch::channel(start_lsn.as_u64());
1803            let data_ready = Arc::clone(&self.data_ready);
1804
1805            let reader_guard = self.task_owner.track().ok_or_else(|| {
1806                ConnectorError::Internal(
1807                    "PostgreSQL CDC connector generation is already retired".into(),
1808                )
1809            })?;
1810
1811            let reader_handle = tokio::spawn(run_wal_reader(
1812                repl_client,
1813                wal_tx,
1814                reader_byte_budget,
1815                raw_wal_byte_limit,
1816                shutdown_rx,
1817                confirmed_lsn_rx,
1818                reader_terminal_error,
1819                data_ready,
1820                reader_guard,
1821            ));
1822
1823            self.wal_rx = Some(wal_rx);
1824            self.wal_byte_budget = Some(wal_byte_budget);
1825            self.wal_terminal_error = Some(terminal_error);
1826            self.reader_handle = Some(reader_handle);
1827            self.reader_shutdown = Some(shutdown_tx);
1828            self.confirmed_lsn_tx = Some(confirmed_lsn_tx);
1829        }
1830
1831        // Publish the new runtime only after all fallible startup work has
1832        // succeeded. A failed start remains a clean Created connector.
1833        self.config = parsed_config;
1834        self.confirmed_flush_lsn = start_lsn;
1835        self.write_lsn = start_lsn;
1836        self.polled_lsn = start_lsn;
1837        self.checkpoint_binding = Some(expected_binding);
1838        self.metrics.set_confirmed_flush_lsn(start_lsn.as_u64());
1839        self.state = ConnectorState::Running;
1840        Ok(())
1841    }
1842
1843    async fn poll_batch(
1844        &mut self,
1845        max_records: usize,
1846    ) -> Result<Option<SourceBatch>, ConnectorError> {
1847        if self.state != ConnectorState::Running {
1848            return Err(ConnectorError::InvalidState {
1849                expected: "Running".to_string(),
1850                actual: self.state.to_string(),
1851            });
1852        }
1853
1854        // Backpressure: stop draining raw WAL before the decoded-stage hard limit. The raw byte
1855        // budget then propagates pressure to the replication reader and PostgreSQL.
1856        {
1857            self.fail_on_terminal_wal_error()?;
1858            let high_watermark = self.config.decoded_high_watermark_bytes();
1859            let decoded_retained_bytes = self.decoded_retained_bytes().inspect_err(|_error| {
1860                self.state = ConnectorState::Failed;
1861            })?;
1862            let mut reader_closed = false;
1863            let must_finish_transaction = self.current_txn.is_some();
1864            let payload_budget = max_records.max(1);
1865            let drain_reader = must_finish_transaction || decoded_retained_bytes < high_watermark;
1866            if !drain_reader && self.pending_payloads.is_empty() {
1867                tracing::debug!(
1868                    retained_bytes = decoded_retained_bytes,
1869                    high_watermark,
1870                    "CDC backpressure active — pausing WAL reader drain"
1871                );
1872            }
1873
1874            let mut processed_payloads = 0_usize;
1875            while processed_payloads < payload_budget {
1876                let payload = if let Some(payload) = self.pending_payloads.pop_front() {
1877                    Some(payload)
1878                } else if drain_reader {
1879                    match self.wal_rx.as_mut().map(|receiver| receiver.try_recv()) {
1880                        Some(Ok(payload)) => Some(payload),
1881                        Some(Err(crossfire::TryRecvError::Empty)) | None => None,
1882                        Some(Err(crossfire::TryRecvError::Disconnected)) => {
1883                            reader_closed = true;
1884                            None
1885                        }
1886                    }
1887                } else {
1888                    None
1889                };
1890                let Some(payload) = payload else {
1891                    break;
1892                };
1893                if let Err(e) = self.process_owned_wal_payload(payload) {
1894                    self.state = ConnectorState::Failed;
1895                    return Err(e);
1896                }
1897                processed_payloads = processed_payloads.checked_add(1).ok_or_else(|| {
1898                    self.state = ConnectorState::Failed;
1899                    ConnectorError::Internal(
1900                        "PostgreSQL CDC poll payload-count accounting overflow".into(),
1901                    )
1902                })?;
1903            }
1904
1905            // Notify ourselves only when a bounded drain demonstrably left work queued.
1906            // Retaining one item avoids both a lost coalesced notification and an
1907            // open-transaction busy loop while the server is genuinely idle.
1908            let reached_payload_budget = processed_payloads == payload_budget;
1909            let may_drain_more = if reached_payload_budget && self.current_txn.is_none() {
1910                self.decoded_retained_bytes().inspect_err(|_error| {
1911                    self.state = ConnectorState::Failed;
1912                })? < high_watermark
1913            } else {
1914                reached_payload_budget
1915            };
1916            if may_drain_more && !reader_closed {
1917                if let Some(ref mut rx) = self.wal_rx {
1918                    match rx.try_recv() {
1919                        Ok(payload) => self.pending_payloads.push_back(payload),
1920                        Err(crossfire::TryRecvError::Empty) => {}
1921                        Err(crossfire::TryRecvError::Disconnected) => reader_closed = true,
1922                    }
1923                }
1924            }
1925            if !self.pending_payloads.is_empty() {
1926                self.data_ready.notify_one();
1927            }
1928            self.fail_on_terminal_wal_error()?;
1929            if reader_closed && self.committed_transactions.is_empty() {
1930                self.state = ConnectorState::Failed;
1931                return Err(ConnectorError::ReadError(
1932                    "WAL reader task terminated unexpectedly — replication stream lost".to_string(),
1933                ));
1934            }
1935        }
1936
1937        #[cfg(test)]
1938        self.process_pending_messages()?;
1939
1940        // Drain buffered events into a RecordBatch.
1941        // Configured Arrow-column extractors derive event-time watermarks from `_ts_ms`.
1942        let result = match self.drain_events(max_records)? {
1943            Some(batch) => {
1944                self.metrics
1945                    .set_confirmed_flush_lsn(self.confirmed_flush_lsn.as_u64());
1946                self.metrics
1947                    .set_replication_lag_bytes(self.replication_lag_bytes());
1948
1949                Ok(Some(SourceBatch::new(batch)))
1950            }
1951            None => Ok(None),
1952        };
1953        let emitted_batch = matches!(&result, Ok(Some(_)));
1954        if max_records > 0 && (emitted_batch || !self.committed_transactions.is_empty()) {
1955            self.data_ready.notify_one();
1956        }
1957        result
1958    }
1959
1960    fn schema(&self) -> SchemaRef {
1961        Arc::clone(&self.schema)
1962    }
1963
1964    fn checkpoint(&self) -> SourceCheckpoint {
1965        let mut cp = SourceCheckpoint::new();
1966        // polled_lsn = latest position drained into a batch — the resumable point recorded in the
1967        // manifest. The PG slot is NOT advanced here: doing so per poll lets PG reclaim WAL for
1968        // data that is only in-pipeline, so a crash loses an LSN range recovery still needs.
1969        // Slot feedback is deferred to notify_epoch_committed (durable-commit only).
1970        cp.set_offset("lsn", self.polled_lsn.to_string());
1971        cp.set_metadata("slot_name", &self.config.slot_name);
1972        cp.set_metadata("publication", &self.config.publication);
1973        cp.set_metadata("database", &self.config.database);
1974        if let Some(binding) = &self.checkpoint_binding {
1975            write_checkpoint_binding(&mut cp, binding);
1976        }
1977        cp
1978    }
1979
1980    async fn notify_epoch_committed(
1981        &mut self,
1982        epoch: u64,
1983        checkpoint: &SourceCheckpoint,
1984    ) -> Result<(), ConnectorError> {
1985        // Advance the PG replication slot only after the epoch is durably committed (manifest
1986        // persisted + sinks committed), so PG never reclaims WAL for data still in-pipeline.
1987        // The checkpoint carries the exact LSN persisted for this epoch; a timer-driven empty
1988        // checkpoint has no "lsn" offset and is a no-op.
1989        let Some(lsn_str) = checkpoint.get_offset("lsn") else {
1990            return Ok(());
1991        };
1992        let lsn = lsn_str.parse::<Lsn>().map_err(|error| {
1993            ConnectorError::ConfigurationError(format!(
1994                "committed PostgreSQL CDC epoch {epoch} contains invalid LSN '{lsn_str}': {error}"
1995            ))
1996        })?;
1997        let context = format!("committed epoch {epoch} checkpoint");
1998        let committed_binding = validate_checkpoint_identity(checkpoint, &self.config, &context)?;
1999        let active_binding =
2000            self.checkpoint_binding
2001                .as_ref()
2002                .ok_or_else(|| ConnectorError::InvalidState {
2003                    expected: "running PostgreSQL CDC checkpoint binding".into(),
2004                    actual: "checkpoint binding is missing".into(),
2005                })?;
2006        validate_live_binding(&committed_binding, active_binding, &context)?;
2007        if lsn.as_u64() > self.polled_lsn.as_u64() {
2008            return Err(ConnectorError::ConfigurationError(format!(
2009                "committed PostgreSQL CDC epoch {epoch} LSN {lsn} is ahead of the source's polled LSN {}; refusing irreversible slot feedback",
2010                self.polled_lsn
2011            )));
2012        }
2013        // A strictly stale notification is already satisfied and must never regress either cursor.
2014        // An equal notification is handed off again: that is idempotent and repairs feedback after
2015        // a reader restart whose local cursor was restored before its channel was created.
2016        if lsn.as_u64() < self.confirmed_flush_lsn.as_u64() {
2017            return Ok(());
2018        }
2019
2020        let tx = self
2021            .confirmed_lsn_tx
2022            .as_ref()
2023            .ok_or_else(|| ConnectorError::InvalidState {
2024                expected: "running PostgreSQL CDC confirmed-LSN feedback channel".into(),
2025                actual: "feedback channel is missing".into(),
2026            })?;
2027        tx.send(lsn.as_u64()).map_err(|_| {
2028            ConnectorError::ConnectionFailed(
2029                "PostgreSQL CDC confirmed-LSN feedback channel is closed".into(),
2030            )
2031        })?;
2032        // The local cursor is authoritative only after the reader accepted the handoff.
2033        self.confirmed_flush_lsn = lsn;
2034        self.metrics.set_confirmed_flush_lsn(lsn.as_u64());
2035        Ok(())
2036    }
2037
2038    fn contract(&self, config: &ConnectorConfig) -> Result<SourceContract, ConnectorError> {
2039        // The replication slot's WAL is reclaimed only as the confirmed-flush LSN advances, which
2040        // happens on durable commit. Without checkpointing the slot never advances and the source
2041        // database's WAL fills without bound, so this source is commit-coupled.
2042        if config.properties().is_empty() {
2043            self.config.validate()?;
2044        } else {
2045            PostgresCdcConfig::from_config(config)?.validate()?;
2046        }
2047        Ok(SourceContract::new(
2048            SourceConsistency::CommitCoupled,
2049            SourceTopology::Singleton,
2050        ))
2051    }
2052
2053    fn data_ready_notify(&self) -> Option<Arc<Notify>> {
2054        Some(Arc::clone(&self.data_ready))
2055    }
2056
2057    async fn close(&mut self) -> Result<(), ConnectorError> {
2058        // Keep both fields installed while awaiting. If this close future is
2059        // cancelled, the same instance still owns the reader and can retry.
2060        if let Some(tx) = self.reader_shutdown.as_ref() {
2061            tx.send_replace(true);
2062        }
2063        let detach_reader = if let Some(handle) = self.reader_handle.as_mut() {
2064            tokio::time::timeout(std::time::Duration::from_secs(5), &mut *handle)
2065                .await
2066                .is_err()
2067        } else {
2068            false
2069        };
2070        if detach_reader {
2071            tracing::warn!(
2072                "PostgreSQL CDC reader did not stop before the close deadline; its tracked reaper retains shutdown ownership"
2073            );
2074            let handle = self
2075                .reader_handle
2076                .take()
2077                .expect("reader handle was present while awaiting it");
2078            reap_postgres_reader(handle, &self.task_owner);
2079        }
2080        self.reader_handle = None;
2081        self.reader_shutdown = None;
2082        self.wal_rx = None;
2083        self.confirmed_lsn_tx = None;
2084        self.pending_payloads.clear();
2085        self.wal_byte_budget = None;
2086        self.wal_terminal_error = None;
2087
2088        self.state = ConnectorState::Closed;
2089        self.committed_transactions.clear();
2090        self.current_txn = None;
2091        self.relation_cache.clear();
2092        self.buffered_event_count = 0;
2093        self.buffered_event_bytes = 0;
2094        #[cfg(test)]
2095        self.pending_messages.clear();
2096        Ok(())
2097    }
2098}
2099
2100// ── Test helpers ──
2101
2102#[cfg(test)]
2103impl PostgresCdcSource {
2104    /// Injects a pre-built change event directly into the event buffer.
2105    fn inject_event(&mut self, event: ChangeEvent) {
2106        let end_lsn = event.lsn;
2107        self.buffered_event_bytes = self
2108            .buffered_event_bytes
2109            .checked_add(retained_event_bytes(&event).expect("test event size must be valid"))
2110            .expect("test buffered-event bytes must be valid");
2111        self.committed_transactions.push_back(CommittedTransaction {
2112            end_lsn,
2113            events: VecDeque::from([event]),
2114        });
2115        self.buffered_event_count += 1;
2116    }
2117
2118    /// Builds a binary pgoutput Relation message for testing.
2119    fn build_relation_message(
2120        relation_id: u32,
2121        namespace: &str,
2122        name: &str,
2123        columns: &[(u8, &str, u32, i32)], // (flags, name, oid, modifier)
2124    ) -> Vec<u8> {
2125        let mut buf = vec![b'R'];
2126        buf.extend_from_slice(&relation_id.to_be_bytes());
2127        buf.extend_from_slice(namespace.as_bytes());
2128        buf.push(0);
2129        buf.extend_from_slice(name.as_bytes());
2130        buf.push(0);
2131        buf.push(b'd'); // replica identity = default
2132        buf.extend_from_slice(&(columns.len() as i16).to_be_bytes());
2133        for (flags, col_name, oid, modifier) in columns {
2134            buf.push(*flags);
2135            buf.extend_from_slice(col_name.as_bytes());
2136            buf.push(0);
2137            buf.extend_from_slice(&oid.to_be_bytes());
2138            buf.extend_from_slice(&modifier.to_be_bytes());
2139        }
2140        buf
2141    }
2142
2143    /// Builds a binary pgoutput Begin message for testing.
2144    fn build_begin_message(final_lsn: u64, commit_ts_us: i64, xid: u32) -> Vec<u8> {
2145        let mut buf = vec![b'B'];
2146        buf.extend_from_slice(&final_lsn.to_be_bytes());
2147        buf.extend_from_slice(&commit_ts_us.to_be_bytes());
2148        buf.extend_from_slice(&xid.to_be_bytes());
2149        buf
2150    }
2151
2152    /// Builds a binary pgoutput Commit message for testing.
2153    fn build_commit_message(commit_lsn: u64, end_lsn: u64, commit_ts_us: i64) -> Vec<u8> {
2154        let mut buf = vec![b'C'];
2155        buf.push(0); // flags
2156        buf.extend_from_slice(&commit_lsn.to_be_bytes());
2157        buf.extend_from_slice(&end_lsn.to_be_bytes());
2158        buf.extend_from_slice(&commit_ts_us.to_be_bytes());
2159        buf
2160    }
2161
2162    /// Builds a binary pgoutput Insert message for testing.
2163    fn build_insert_message(relation_id: u32, values: &[Option<&str>]) -> Vec<u8> {
2164        let mut buf = vec![b'I'];
2165        buf.extend_from_slice(&relation_id.to_be_bytes());
2166        buf.push(b'N');
2167        buf.extend_from_slice(&(values.len() as i16).to_be_bytes());
2168        for val in values {
2169            match val {
2170                Some(s) => {
2171                    buf.push(b't');
2172                    buf.extend_from_slice(&(s.len() as i32).to_be_bytes());
2173                    buf.extend_from_slice(s.as_bytes());
2174                }
2175                None => buf.push(b'n'),
2176            }
2177        }
2178        buf
2179    }
2180
2181    /// Builds a binary pgoutput Delete message for testing.
2182    fn build_delete_message(relation_id: u32, values: &[Option<&str>]) -> Vec<u8> {
2183        let mut buf = vec![b'D'];
2184        buf.extend_from_slice(&relation_id.to_be_bytes());
2185        buf.push(b'K'); // key identity
2186        buf.extend_from_slice(&(values.len() as i16).to_be_bytes());
2187        for val in values {
2188            match val {
2189                Some(s) => {
2190                    buf.push(b't');
2191                    buf.extend_from_slice(&(s.len() as i32).to_be_bytes());
2192                    buf.extend_from_slice(s.as_bytes());
2193                }
2194                None => buf.push(b'n'),
2195            }
2196        }
2197        buf
2198    }
2199
2200    /// Builds a binary pgoutput Truncate message for testing.
2201    fn build_truncate_message(relation_ids: &[u32], options: u8) -> Vec<u8> {
2202        let mut buf = vec![b'T'];
2203        buf.extend_from_slice(&(relation_ids.len() as i32).to_be_bytes());
2204        buf.push(options);
2205        for id in relation_ids {
2206            buf.extend_from_slice(&id.to_be_bytes());
2207        }
2208        buf
2209    }
2210
2211    /// Builds a binary pgoutput Update message (with old tuple) for testing.
2212    fn build_update_message(
2213        relation_id: u32,
2214        old_tuple_tag: u8,
2215        old_values: &[Option<&str>],
2216        new_values: &[Option<&str>],
2217    ) -> Vec<u8> {
2218        assert!(matches!(old_tuple_tag, b'K' | b'O'));
2219        let mut buf = vec![b'U'];
2220        buf.extend_from_slice(&relation_id.to_be_bytes());
2221        buf.push(old_tuple_tag);
2222        buf.extend_from_slice(&(old_values.len() as i16).to_be_bytes());
2223        for val in old_values {
2224            match val {
2225                Some(s) => {
2226                    buf.push(b't');
2227                    buf.extend_from_slice(&(s.len() as i32).to_be_bytes());
2228                    buf.extend_from_slice(s.as_bytes());
2229                }
2230                None => buf.push(b'n'),
2231            }
2232        }
2233        // New tuple
2234        buf.push(b'N');
2235        buf.extend_from_slice(&(new_values.len() as i16).to_be_bytes());
2236        for val in new_values {
2237            match val {
2238                Some(s) => {
2239                    buf.push(b't');
2240                    buf.extend_from_slice(&(s.len() as i32).to_be_bytes());
2241                    buf.extend_from_slice(s.as_bytes());
2242                }
2243                None => buf.push(b'n'),
2244            }
2245        }
2246        buf
2247    }
2248}
2249
2250#[cfg(test)]
2251mod tests;