laminar_connectors/postgres/cdc/source/
lifecycle.rs1#[cfg(not(test))]
4use super::startup::{prepare_reader_runtime, PreparedReaderRuntime};
5use super::{
6 async_trait, reap_postgres_reader, validate_checkpoint_identity, validate_live_binding,
7 write_checkpoint_binding, Arc, BTreeMap, ConnectorConfig, ConnectorError, ConnectorState,
8 ConnectorTaskTracker, Lsn, Notify, PostgresCdcConfig, PostgresCdcSource,
9 PostgresCheckpointBinding, SchemaRef, SourceBatch, SourceCheckpoint, SourceConnector,
10 SourceContract, SourcePosition, SourceStart, INITIAL_BOOTSTRAP_NOT_ADMITTED,
11};
12
13struct PreparedSourceStart {
14 config: PostgresCdcConfig,
15 start_lsn: Lsn,
16 checkpoint_binding: PostgresCheckpointBinding,
17}
18
19fn prepare_source_start(
20 current_config: &PostgresCdcConfig,
21 config: &ConnectorConfig,
22 position: SourcePosition,
23) -> Result<PreparedSourceStart, ConnectorError> {
24 let mut config = if config.properties().is_empty() {
25 current_config.clone()
26 } else {
27 PostgresCdcConfig::from_config(config)?
28 };
29 config.normalize_table_filters();
30 config.validate()?;
31
32 let SourcePosition::Resume {
33 attempt,
34 checkpoint,
35 } = position
36 else {
37 return Err(ConnectorError::ConfigurationError(
38 INITIAL_BOOTSTRAP_NOT_ADMITTED.into(),
39 ));
40 };
41 let context = format!("checkpoint {attempt:?}");
42 let checkpoint_binding = validate_checkpoint_identity(&checkpoint, &config, &context)?;
43 let lsn_str = checkpoint.get_offset("lsn").ok_or_else(|| {
44 ConnectorError::ConfigurationError(format!(
45 "PostgreSQL CDC checkpoint {attempt:?} is missing required 'lsn' offset"
46 ))
47 })?;
48 let start_lsn = lsn_str.parse::<Lsn>().map_err(|error| {
49 ConnectorError::ConfigurationError(format!(
50 "invalid LSN '{lsn_str}' in PostgreSQL CDC checkpoint {attempt:?}: {error}"
51 ))
52 })?;
53 Ok(PreparedSourceStart {
54 config,
55 start_lsn,
56 checkpoint_binding,
57 })
58}
59
60#[cfg(not(test))]
61fn install_reader_runtime(source: &mut PostgresCdcSource, runtime: PreparedReaderRuntime) {
62 source.wal_rx = Some(runtime.wal_rx);
63 source.wal_byte_budget = Some(runtime.wal_byte_budget);
64 source.wal_terminal_error = Some(runtime.terminal_error);
65 source.reader_handle = Some(runtime.reader_handle);
66 source.reader_shutdown = Some(runtime.shutdown_tx);
67 source.confirmed_lsn_tx = Some(runtime.confirmed_lsn_tx);
68}
69
70#[async_trait]
71impl SourceConnector for PostgresCdcSource {
72 fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
73 Some(self.task_tracker.clone())
74 }
75
76 fn recovery_identity_options(
77 &self,
78 config: &ConnectorConfig,
79 ) -> Result<Option<BTreeMap<String, String>>, ConnectorError> {
80 let mut parsed = if config.properties().is_empty() {
81 self.config.clone()
82 } else {
83 PostgresCdcConfig::from_config(config)?
84 };
85 parsed.normalize_table_filters();
86 parsed.validate()?;
87
88 Ok(Some(BTreeMap::from([
89 ("database".into(), parsed.database),
90 ("publication".into(), parsed.publication),
91 ("slot.name".into(), parsed.slot_name),
92 ("table.exclude".into(), parsed.table_exclude.join(",")),
93 ("table.include".into(), parsed.table_include.join(",")),
94 ("wire.protocol".into(), "pgoutput-v1".into()),
95 ])))
96 }
97
98 async fn start(&mut self, request: SourceStart) -> Result<(), ConnectorError> {
99 if self.state != ConnectorState::Created {
100 return Err(ConnectorError::InvalidState {
101 expected: ConnectorState::Created.to_string(),
102 actual: self.state.to_string(),
103 });
104 }
105 let (config, position, _) = request.into_parts();
106 let prepared = prepare_source_start(&self.config, &config, position)?;
107
108 #[cfg(not(test))]
109 {
110 let runtime = prepare_reader_runtime(
111 self,
112 &prepared.config,
113 &prepared.checkpoint_binding,
114 prepared.start_lsn,
115 )
116 .await?;
117 install_reader_runtime(self, runtime);
118 }
119
120 self.config = prepared.config;
123 self.confirmed_flush_lsn = prepared.start_lsn;
124 self.write_lsn = prepared.start_lsn;
125 self.polled_lsn = prepared.start_lsn;
126 self.checkpoint_binding = Some(prepared.checkpoint_binding);
127 self.metrics
128 .set_confirmed_flush_lsn(prepared.start_lsn.as_u64());
129 self.state = ConnectorState::Running;
130 Ok(())
131 }
132
133 async fn poll_batch(
134 &mut self,
135 max_records: usize,
136 ) -> Result<Option<SourceBatch>, ConnectorError> {
137 if self.state != ConnectorState::Running {
138 return Err(ConnectorError::InvalidState {
139 expected: "Running".to_string(),
140 actual: self.state.to_string(),
141 });
142 }
143
144 {
147 self.fail_on_terminal_wal_error()?;
148 let high_watermark = self.config.decoded_high_watermark_bytes();
149 let decoded_retained_bytes = self.decoded_retained_bytes().inspect_err(|_error| {
150 self.state = ConnectorState::Failed;
151 })?;
152 let mut reader_closed = false;
153 let must_finish_transaction = self.current_txn.is_some();
154 let payload_budget = max_records.max(1);
155 let drain_reader = must_finish_transaction || decoded_retained_bytes < high_watermark;
156 if !drain_reader && self.pending_payloads.is_empty() {
157 tracing::debug!(
158 retained_bytes = decoded_retained_bytes,
159 high_watermark,
160 "CDC backpressure active — pausing WAL reader drain"
161 );
162 }
163
164 let mut processed_payloads = 0_usize;
165 while processed_payloads < payload_budget {
166 let payload = if let Some(payload) = self.pending_payloads.pop_front() {
167 Some(payload)
168 } else if drain_reader {
169 match self.wal_rx.as_mut().map(|receiver| receiver.try_recv()) {
170 Some(Ok(payload)) => Some(payload),
171 Some(Err(crossfire::TryRecvError::Empty)) | None => None,
172 Some(Err(crossfire::TryRecvError::Disconnected)) => {
173 reader_closed = true;
174 None
175 }
176 }
177 } else {
178 None
179 };
180 let Some(payload) = payload else {
181 break;
182 };
183 if let Err(e) = self.process_owned_wal_payload(payload) {
184 self.state = ConnectorState::Failed;
185 return Err(e);
186 }
187 processed_payloads = processed_payloads.checked_add(1).ok_or_else(|| {
188 self.state = ConnectorState::Failed;
189 ConnectorError::Internal(
190 "PostgreSQL CDC poll payload-count accounting overflow".into(),
191 )
192 })?;
193 }
194
195 let reached_payload_budget = processed_payloads == payload_budget;
199 let may_drain_more = if reached_payload_budget && self.current_txn.is_none() {
200 self.decoded_retained_bytes().inspect_err(|_error| {
201 self.state = ConnectorState::Failed;
202 })? < high_watermark
203 } else {
204 reached_payload_budget
205 };
206 if may_drain_more && !reader_closed {
207 if let Some(ref mut rx) = self.wal_rx {
208 match rx.try_recv() {
209 Ok(payload) => self.pending_payloads.push_back(payload),
210 Err(crossfire::TryRecvError::Empty) => {}
211 Err(crossfire::TryRecvError::Disconnected) => reader_closed = true,
212 }
213 }
214 }
215 if !self.pending_payloads.is_empty() {
216 self.data_ready.notify_one();
217 }
218 self.fail_on_terminal_wal_error()?;
219 if reader_closed && self.committed_transactions.is_empty() {
220 self.state = ConnectorState::Failed;
221 return Err(ConnectorError::ReadError(
222 "WAL reader task terminated unexpectedly — replication stream lost".to_string(),
223 ));
224 }
225 }
226
227 #[cfg(test)]
228 self.process_pending_messages()?;
229
230 let result = match self.drain_events(max_records)? {
233 Some(batch) => {
234 self.metrics
235 .set_confirmed_flush_lsn(self.confirmed_flush_lsn.as_u64());
236 self.metrics
237 .set_replication_lag_bytes(self.replication_lag_bytes());
238
239 Ok(Some(SourceBatch::new(batch)))
240 }
241 None => Ok(None),
242 };
243 let emitted_batch = matches!(&result, Ok(Some(_)));
244 if max_records > 0 && (emitted_batch || !self.committed_transactions.is_empty()) {
245 self.data_ready.notify_one();
246 }
247 result
248 }
249
250 fn schema(&self) -> SchemaRef {
251 Arc::clone(&self.schema)
252 }
253
254 fn checkpoint(&self) -> SourceCheckpoint {
255 let mut cp = SourceCheckpoint::new();
256 cp.set_offset("lsn", self.polled_lsn.to_string());
261 cp.set_metadata("slot_name", &self.config.slot_name);
262 cp.set_metadata("publication", &self.config.publication);
263 cp.set_metadata("database", &self.config.database);
264 if let Some(binding) = &self.checkpoint_binding {
265 write_checkpoint_binding(&mut cp, binding);
266 }
267 cp
268 }
269
270 async fn notify_epoch_committed(
271 &mut self,
272 epoch: u64,
273 checkpoint: &SourceCheckpoint,
274 ) -> Result<(), ConnectorError> {
275 let Some(lsn_str) = checkpoint.get_offset("lsn") else {
280 return Ok(());
281 };
282 let lsn = lsn_str.parse::<Lsn>().map_err(|error| {
283 ConnectorError::ConfigurationError(format!(
284 "committed PostgreSQL CDC epoch {epoch} contains invalid LSN '{lsn_str}': {error}"
285 ))
286 })?;
287 let context = format!("committed epoch {epoch} checkpoint");
288 let committed_binding = validate_checkpoint_identity(checkpoint, &self.config, &context)?;
289 let active_binding =
290 self.checkpoint_binding
291 .as_ref()
292 .ok_or_else(|| ConnectorError::InvalidState {
293 expected: "running PostgreSQL CDC checkpoint binding".into(),
294 actual: "checkpoint binding is missing".into(),
295 })?;
296 validate_live_binding(&committed_binding, active_binding, &context)?;
297 if lsn.as_u64() > self.polled_lsn.as_u64() {
298 return Err(ConnectorError::ConfigurationError(format!(
299 "committed PostgreSQL CDC epoch {epoch} LSN {lsn} is ahead of the source's polled LSN {}; refusing irreversible slot feedback",
300 self.polled_lsn
301 )));
302 }
303 if lsn.as_u64() < self.confirmed_flush_lsn.as_u64() {
307 return Ok(());
308 }
309
310 let tx = self
311 .confirmed_lsn_tx
312 .as_ref()
313 .ok_or_else(|| ConnectorError::InvalidState {
314 expected: "running PostgreSQL CDC confirmed-LSN feedback channel".into(),
315 actual: "feedback channel is missing".into(),
316 })?;
317 tx.send(lsn.as_u64()).map_err(|_| {
318 ConnectorError::ConnectionFailed(
319 "PostgreSQL CDC confirmed-LSN feedback channel is closed".into(),
320 )
321 })?;
322 self.confirmed_flush_lsn = lsn;
324 self.metrics.set_confirmed_flush_lsn(lsn.as_u64());
325 Ok(())
326 }
327
328 fn contract(&self, config: &ConnectorConfig) -> Result<SourceContract, ConnectorError> {
329 if config.properties().is_empty() {
333 self.config.validate()?;
334 } else {
335 PostgresCdcConfig::from_config(config)?.validate()?;
336 }
337 Err(ConnectorError::ConfigurationError(
338 "PostgreSQL CDC emits a raw JSON change envelope; canonical primary-keyed row/delete records are required"
339 .into(),
340 ))
341 }
342
343 fn data_ready_notify(&self) -> Option<Arc<Notify>> {
344 Some(Arc::clone(&self.data_ready))
345 }
346
347 async fn close(&mut self) -> Result<(), ConnectorError> {
348 if let Some(tx) = self.reader_shutdown.as_ref() {
351 tx.send_replace(true);
352 }
353 let detach_reader = if let Some(handle) = self.reader_handle.as_mut() {
354 tokio::time::timeout(std::time::Duration::from_secs(5), &mut *handle)
355 .await
356 .is_err()
357 } else {
358 false
359 };
360 if detach_reader {
361 tracing::warn!(
362 "PostgreSQL CDC reader did not stop before the close deadline; its tracked reaper retains shutdown ownership"
363 );
364 let handle = self
365 .reader_handle
366 .take()
367 .expect("reader handle was present while awaiting it");
368 reap_postgres_reader(handle, &self.task_owner);
369 }
370 self.reader_handle = None;
371 self.reader_shutdown = None;
372 self.wal_rx = None;
373 self.confirmed_lsn_tx = None;
374 self.pending_payloads.clear();
375 self.wal_byte_budget = None;
376 self.wal_terminal_error = None;
377
378 self.state = ConnectorState::Closed;
379 self.committed_transactions.clear();
380 self.current_txn = None;
381 self.relation_cache.clear();
382 self.buffered_event_count = 0;
383 self.buffered_event_bytes = 0;
384 #[cfg(test)]
385 self.pending_messages.clear();
386 Ok(())
387 }
388}