1use std::time::Duration;
20
21use arrow_array::RecordBatch;
22use arrow_schema::SchemaRef;
23use async_trait::async_trait;
24use tracing::info;
25
26#[cfg(feature = "postgres-sink")]
27use crate::changelog::collapse_changelog;
28use crate::config::{ConnectorConfig, ConnectorState};
29use crate::connector::{
30 SinkConnector, SinkConsistency, SinkContract, SinkInputMode, SinkTopology, WriteResult,
31};
32use crate::error::ConnectorError;
33
34use super::sink_config::{PostgresSinkConfig, WriteMode};
35use super::sink_metrics::PostgresSinkMetrics;
36
37mod input;
38#[cfg(feature = "postgres-sink")]
39mod pacing;
40#[cfg(feature = "postgres-sink")]
41mod schema_resolution;
42mod statements;
43
44use input::{build_user_schema, validate_sink_schema};
45#[cfg(any(feature = "postgres-sink", test))]
46use input::{collapse_upsert_batch, requires_preflush, retained_batch_bytes};
47#[cfg(feature = "postgres-sink")]
48use input::{
49 execute_unnest, retained_batch_bytes_u64, strip_metadata_columns, validate_changelog_input,
50 validate_input_batch,
51};
52#[cfg(feature = "postgres-sink")]
53use pacing::{ApplyPacing, Statement};
54
55#[cfg(feature = "postgres-sink")]
56fn postgres_dispatched_write_error(
57 operation: &str,
58 error: &tokio_postgres::Error,
59) -> ConnectorError {
60 match error.as_db_error() {
62 Some(server) => classify_postgres_write_failure(operation, server, true),
63 None => classify_postgres_write_failure(operation, error, false),
64 }
65}
66
67#[cfg(any(feature = "postgres-sink", test))]
68fn classify_postgres_write_failure(
69 operation: &str,
70 error: &dyn std::fmt::Display,
71 server_rejected: bool,
72) -> ConnectorError {
73 if server_rejected {
74 ConnectorError::WriteError(format!("{operation}: {error}"))
75 } else {
76 ConnectorError::outcome_unknown(
77 format!(
78 "PostgreSQL {operation} failed without a server response and may have committed: {error}"
79 ),
80 true,
81 )
82 }
83}
84
85#[cfg(any(feature = "postgres-sink", test))]
86fn resolve_uncommitted_transaction_error(error: ConnectorError) -> ConnectorError {
87 match error {
88 ConnectorError::OutcomeUnknown { message, retryable } if retryable => {
89 ConnectorError::ConnectionFailed(format!(
90 "PostgreSQL transaction mutation failed before COMMIT; no commit was dispatched: {message}"
91 ))
92 }
93 ConnectorError::OutcomeUnknown { message, .. } => ConnectorError::TransactionError(
94 format!(
95 "PostgreSQL transaction mutation failed before COMMIT; no commit was dispatched: {message}"
96 ),
97 ),
98 error => error,
99 }
100}
101#[cfg(feature = "postgres-sink")]
102use super::types::{arrow_column_to_pg_array, postgres_copy_batch};
103#[cfg(feature = "postgres-sink")]
104use bytes::BytesMut;
105#[cfg(feature = "postgres-sink")]
106use deadpool_postgres::Pool;
107
108#[cfg(feature = "postgres-sink")]
112const MAX_BUFFERED_RETAINED_BYTES: usize = 16 * 1024 * 1024;
113#[cfg(feature = "postgres-sink")]
114const COPY_ENCODE_INITIAL_CAPACITY: usize = 64 * 1024;
115#[cfg(feature = "postgres-sink")]
116const SINK_POOL_SIZE: usize = 1;
117
118#[cfg(feature = "postgres-sink")]
119fn build_pool(config: &PostgresSinkConfig) -> Result<Pool, ConnectorError> {
120 config.validate()?;
121
122 let mut pool_config = deadpool_postgres::Config::new();
123 pool_config.host = Some(config.hostname.clone());
124 pool_config.port = Some(config.port);
125 pool_config.dbname = Some(config.database.clone());
126 pool_config.user = Some(config.username.clone());
127 pool_config.password = Some(config.password.clone());
128 pool_config.options = Some(config.statement_timeout_startup_option());
129 pool_config.connect_timeout = Some(config.connect_timeout);
130 pool_config.ssl_mode = Some(match config.ssl_mode {
131 crate::postgres::SslMode::Disable => deadpool_postgres::SslMode::Disable,
132 crate::postgres::SslMode::VerifyFull => deadpool_postgres::SslMode::Require,
133 });
134 let mut deadpool_config = deadpool_postgres::PoolConfig::new(SINK_POOL_SIZE);
135 deadpool_config.timeouts.wait = Some(config.connect_timeout);
136 deadpool_config.timeouts.create = Some(config.connect_timeout);
137 pool_config.pool = Some(deadpool_config);
138
139 let runtime = Some(deadpool_postgres::Runtime::Tokio1);
140 match config.ssl_mode {
141 crate::postgres::SslMode::Disable => pool_config
142 .create_pool(runtime, tokio_postgres::NoTls)
143 .map_err(|error| {
144 ConnectorError::ConnectionFailed(format!("pool creation failed: {error}"))
145 }),
146 crate::postgres::SslMode::VerifyFull => {
147 let tls = crate::postgres::make_rustls_connector(config.ssl_ca_cert_path.as_deref())?;
148 pool_config.create_pool(runtime, tls).map_err(|error| {
149 ConnectorError::ConnectionFailed(format!("TLS pool creation failed: {error}"))
150 })
151 }
152 }
153}
154
155pub struct PostgresSink {
160 config: PostgresSinkConfig,
162 schema: SchemaRef,
164 user_schema: SchemaRef,
166 #[cfg(feature = "postgres-sink")]
167 target_binding: Option<crate::schema::resolution::SchemaBinding>,
168 state: ConnectorState,
170 buffer: Vec<RecordBatch>,
172 buffered_rows: usize,
174 buffered_retained_bytes: usize,
176 #[cfg(feature = "postgres-sink")]
177 pacing: ApplyPacing,
178 metrics: PostgresSinkMetrics,
180 upsert_sql: Option<String>,
182 copy_sql: Option<String>,
184 create_table_sql: Option<String>,
186 delete_sql: Option<String>,
188 #[cfg(feature = "postgres-sink")]
190 pool: Option<Pool>,
191}
192
193impl PostgresSink {
194 #[must_use]
196 pub fn new(
197 schema: SchemaRef,
198 config: PostgresSinkConfig,
199 registry: Option<&prometheus::Registry>,
200 ) -> Self {
201 let user_schema = build_user_schema(&schema);
202 Self {
203 config,
204 schema,
205 user_schema,
206 #[cfg(feature = "postgres-sink")]
207 target_binding: None,
208 state: ConnectorState::Created,
209 buffer: Vec::with_capacity(4),
210 buffered_rows: 0,
211 buffered_retained_bytes: 0,
212 #[cfg(feature = "postgres-sink")]
213 pacing: ApplyPacing::default(),
214 metrics: PostgresSinkMetrics::new(registry),
215 upsert_sql: None,
216 copy_sql: None,
217 create_table_sql: None,
218 delete_sql: None,
219 #[cfg(feature = "postgres-sink")]
220 pool: None,
221 }
222 }
223
224 pub(crate) fn from_connector_config(
228 config: &ConnectorConfig,
229 registry: Option<&prometheus::Registry>,
230 ) -> Result<Self, ConnectorError> {
231 let (sink_config, schema) = Self::decode_connector_config(config)?;
232 Ok(Self::new(schema, sink_config, registry))
233 }
234
235 fn decode_connector_config(
236 config: &ConnectorConfig,
237 ) -> Result<(PostgresSinkConfig, SchemaRef), ConnectorError> {
238 let sink_config = PostgresSinkConfig::from_config(config)?;
239 if config.get("_arrow_schema").is_none() {
240 return Err(ConnectorError::ConfigurationError(
241 "PostgreSQL sink requires the engine-injected '_arrow_schema'".into(),
242 ));
243 }
244 let schema = config.arrow_schema().ok_or_else(|| {
245 ConnectorError::ConfigurationError(
246 "invalid PostgreSQL sink '_arrow_schema' encoding".into(),
247 )
248 })?;
249 validate_sink_schema(&schema, &sink_config)?;
250 Ok((sink_config, schema))
251 }
252
253 fn apply_connector_config(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
254 let (sink_config, schema) = Self::decode_connector_config(config)?;
255 let user_schema = build_user_schema(&schema);
256 self.config = sink_config;
257 self.schema = schema;
258 self.user_schema = user_schema;
259 Ok(())
260 }
261
262 #[must_use]
264 pub fn state(&self) -> ConnectorState {
265 self.state
266 }
267
268 #[must_use]
270 pub fn buffered_rows(&self) -> usize {
271 self.buffered_rows
272 }
273
274 #[cfg(feature = "postgres-sink")]
275 fn retain_batch(&mut self, batch: &RecordBatch, retained_bytes: usize) {
276 self.buffer.push(batch.clone());
277 self.buffered_rows = self.buffered_rows.saturating_add(batch.num_rows());
278 self.buffered_retained_bytes = self.buffered_retained_bytes.saturating_add(retained_bytes);
279 }
280
281 fn take_buffer(&mut self) -> Vec<RecordBatch> {
284 self.buffered_rows = 0;
285 self.buffered_retained_bytes = 0;
286 std::mem::take(&mut self.buffer)
287 }
288
289 #[must_use]
291 pub fn sink_metrics(&self) -> &PostgresSinkMetrics {
292 &self.metrics
293 }
294
295 fn prepare_statements(&mut self) -> Result<(), ConnectorError> {
299 self.copy_sql = (self.config.write_mode == WriteMode::Append)
300 .then(|| Self::build_copy_sql(&self.schema, &self.config))
301 .transpose()?;
302 self.upsert_sql = (self.config.write_mode == WriteMode::Upsert)
303 .then(|| Self::build_upsert_sql(&self.schema, &self.config))
304 .transpose()?;
305 self.create_table_sql = self
306 .config
307 .auto_create_table
308 .then(|| Self::build_create_table_sql(&self.schema, &self.config))
309 .transpose()?;
310 self.delete_sql = self
311 .config
312 .changelog_mode
313 .then(|| Self::build_delete_sql(&self.schema, &self.config))
314 .transpose()?;
315 Ok(())
316 }
317
318 #[cfg(feature = "postgres-sink")]
320 fn pool(&self) -> Result<&Pool, ConnectorError> {
321 self.pool.as_ref().ok_or(ConnectorError::InvalidState {
322 expected: "pool initialized (call open() first)".into(),
323 actual: "pool not initialized".into(),
324 })
325 }
326
327 #[cfg(feature = "postgres-sink")]
329 fn concat_buffer(&self, buffer: &[RecordBatch]) -> Result<RecordBatch, ConnectorError> {
330 if buffer.is_empty() {
331 return Ok(RecordBatch::new_empty(self.user_schema.clone()));
332 }
333
334 let stripped: Result<Vec<RecordBatch>, ConnectorError> =
335 buffer.iter().map(strip_metadata_columns).collect();
336 let stripped = stripped?;
337
338 arrow_select::concat::concat_batches(&self.user_schema, &stripped)
339 .map_err(|e| ConnectorError::Internal(format!("batch concat failed: {e}")))
340 }
341
342 #[cfg(feature = "postgres-sink")]
344 async fn flush_append(
345 &mut self,
346 client: &tokio_postgres::Transaction<'_>,
347 buffer: &[RecordBatch],
348 ) -> Result<WriteResult, ConnectorError> {
349 let user_batch = self.concat_buffer(buffer)?;
350 if user_batch.num_rows() == 0 {
351 return Ok(WriteResult::new(0, 0));
352 }
353 let (_, encoded_bytes) = self
354 .apply_statements(client, Statement::Copy, &user_batch)
355 .await?;
356
357 let rows = user_batch.num_rows();
358 self.metrics.record_write(rows as u64, encoded_bytes);
359 self.metrics.record_flush();
360 self.metrics.record_copy();
361
362 Ok(WriteResult::new(rows, encoded_bytes))
363 }
364
365 #[cfg(feature = "postgres-sink")]
367 async fn copy_rows(
368 &self,
369 client: &tokio_postgres::Transaction<'_>,
370 batch: &RecordBatch,
371 ) -> Result<u64, ConnectorError> {
372 let copy_batch = postgres_copy_batch(batch)?;
373 let mut encoder = pgpq::ArrowToPostgresBinaryEncoder::try_new(copy_batch.schema().as_ref())
374 .map_err(|e| ConnectorError::Internal(format!("pgpq encoder init: {e}")))?;
375
376 let mut encode_buf = BytesMut::with_capacity(COPY_ENCODE_INITIAL_CAPACITY);
379 encoder.write_header(&mut encode_buf);
380 encoder
381 .write_batch(©_batch, &mut encode_buf)
382 .map_err(|e| ConnectorError::Internal(format!("pgpq encode: {e}")))?;
383 encoder
384 .write_footer(&mut encode_buf)
385 .map_err(|e| ConnectorError::Internal(format!("pgpq footer: {e}")))?;
386
387 let encoded_bytes = encode_buf.len();
388 let bytes_to_send = encode_buf.freeze();
389
390 let copy_sql = self
391 .copy_sql
392 .as_deref()
393 .ok_or_else(|| ConnectorError::Internal("COPY SQL not prepared".into()))?;
394
395 let sink = client
396 .copy_in(copy_sql)
397 .await
398 .map_err(|error| postgres_dispatched_write_error("COPY start", &error))?;
399
400 {
401 use futures_util::SinkExt;
402 futures_util::pin_mut!(sink);
403 sink.send(bytes_to_send)
404 .await
405 .map_err(|error| postgres_dispatched_write_error("COPY send", &error))?;
406 sink.close()
407 .await
408 .map_err(|error| postgres_dispatched_write_error("COPY finish", &error))?;
409 }
410 Ok(encoded_bytes as u64)
411 }
412
413 #[cfg(feature = "postgres-sink")]
417 async fn apply_statements(
418 &mut self,
419 client: &tokio_postgres::Transaction<'_>,
420 statement: Statement,
421 batch: &RecordBatch,
422 ) -> Result<(u64, u64), ConnectorError> {
423 let mut reported = 0_u64;
424 let mut copied_bytes = 0_u64;
425 let mut offset = 0;
426 while offset < batch.num_rows() {
427 let rows = self.pacing.statement_rows(
428 statement,
429 batch.num_rows() - offset,
430 self.config.statement_timeout,
431 );
432 let chunk = batch.slice(offset, rows);
433 let started = std::time::Instant::now();
434 let applied = match statement {
435 Statement::Copy => {
436 let bytes = self.copy_rows(client, &chunk).await?;
437 copied_bytes = copied_bytes.saturating_add(bytes);
438 rows as u64
439 }
440 Statement::Upsert => {
441 let upsert_sql = self.upsert_sql.as_deref().ok_or_else(|| {
442 ConnectorError::Internal("upsert SQL not prepared".into())
443 })?;
444 execute_unnest(client, upsert_sql, &chunk).await?
445 }
446 Statement::Delete => self.execute_deletes(client, &chunk).await? as u64,
447 };
448 self.pacing.record(statement, rows, started.elapsed());
449 reported = reported.saturating_add(applied);
450 offset += rows;
451 }
452 Ok((reported, copied_bytes))
453 }
454
455 #[cfg(feature = "postgres-sink")]
457 #[allow(clippy::cast_possible_truncation)]
458 async fn flush_upsert(
459 &mut self,
460 client: &tokio_postgres::Transaction<'_>,
461 buffer: &[RecordBatch],
462 ) -> Result<WriteResult, ConnectorError> {
463 if self.config.changelog_mode {
464 return self.flush_changelog(client, buffer).await;
465 }
466
467 let user_batch = self.concat_buffer(buffer)?;
468 if user_batch.num_rows() == 0 {
469 return Ok(WriteResult::new(0, 0));
470 }
471 let user_batch = collapse_upsert_batch(&user_batch, &self.config.primary_key_columns)?;
472 let (rows, _) = self
473 .apply_statements(client, Statement::Upsert, &user_batch)
474 .await?;
475
476 let byte_estimate = retained_batch_bytes_u64(&user_batch);
477 self.metrics.record_write(rows, byte_estimate);
478 self.metrics.record_flush();
479 self.metrics.record_upsert();
480
481 Ok(WriteResult::new(rows as usize, byte_estimate))
482 }
483
484 #[cfg(feature = "postgres-sink")]
487 #[allow(clippy::cast_possible_truncation)]
488 async fn flush_changelog(
489 &mut self,
490 client: &tokio_postgres::Transaction<'_>,
491 buffer: &[RecordBatch],
492 ) -> Result<WriteResult, ConnectorError> {
493 if buffer.is_empty() {
494 return Ok(WriteResult::new(0, 0));
495 }
496
497 for batch in buffer {
500 validate_changelog_input(batch)?;
501 }
502
503 let split_input: Vec<RecordBatch> = {
512 let schema = buffer[0].schema();
513 let combined = arrow_select::concat::concat_batches(&schema, buffer)
514 .map_err(|e| ConnectorError::Internal(format!("concat changelog: {e}")))?;
515 vec![collapse_changelog(
516 &combined,
517 &self.config.primary_key_columns,
518 )?]
519 };
520
521 let mut all_inserts = Vec::new();
523 let mut all_deletes = Vec::new();
524 for batch in &split_input {
525 let (ins, del) = Self::split_changelog_batch(batch)?;
526 if ins.num_rows() > 0 {
527 all_inserts.push(ins);
528 }
529 if del.num_rows() > 0 {
530 all_deletes.push(del);
531 }
532 }
533
534 let mut upserted = 0_u64;
535 let mut deleted = 0_u64;
536 let mut total_bytes = 0_u64;
537 if !all_inserts.is_empty() {
538 let insert_batch =
539 arrow_select::concat::concat_batches(&self.user_schema, &all_inserts)
540 .map_err(|e| ConnectorError::Internal(format!("concat inserts: {e}")))?;
541 (upserted, _) = self
542 .apply_statements(client, Statement::Upsert, &insert_batch)
543 .await?;
544 total_bytes = retained_batch_bytes_u64(&insert_batch);
545 }
546 if !all_deletes.is_empty() {
547 let delete_batch =
548 arrow_select::concat::concat_batches(&self.user_schema, &all_deletes)
549 .map_err(|e| ConnectorError::Internal(format!("concat deletes: {e}")))?;
550 total_bytes = total_bytes.saturating_add(retained_batch_bytes_u64(&delete_batch));
551 (deleted, _) = self
552 .apply_statements(client, Statement::Delete, &delete_batch)
553 .await?;
554 }
555
556 let total_rows = upserted.saturating_add(deleted);
557 if total_rows != 0 {
558 self.metrics.record_write(total_rows, total_bytes);
559 }
560 if upserted != 0 {
561 self.metrics.record_upsert();
562 }
563 if deleted != 0 {
564 self.metrics.record_deletes(deleted);
565 }
566 self.metrics.record_flush();
567 Ok(WriteResult::new(total_rows as usize, total_bytes))
568 }
569
570 #[cfg(feature = "postgres-sink")]
572 #[allow(clippy::cast_possible_truncation)]
573 async fn execute_deletes<C>(
574 &self,
575 client: &C,
576 delete_batch: &RecordBatch,
577 ) -> Result<usize, ConnectorError>
578 where
579 C: tokio_postgres::GenericClient + Sync,
580 {
581 if delete_batch.num_rows() == 0 {
582 return Ok(0);
583 }
584
585 let delete_sql = self
586 .delete_sql
587 .as_deref()
588 .ok_or_else(|| ConnectorError::Internal("DELETE SQL not prepared".into()))?;
589
590 let pk_params: Vec<Box<dyn postgres_types::ToSql + Sync + Send>> = self
591 .config
592 .primary_key_columns
593 .iter()
594 .map(|col| {
595 let idx = delete_batch.schema().index_of(col).map_err(|_| {
596 ConnectorError::ConfigurationError(format!(
597 "primary key column '{col}' not in delete batch"
598 ))
599 })?;
600 arrow_column_to_pg_array(delete_batch.column(idx))
601 })
602 .collect::<Result<_, _>>()?;
603
604 let pk_refs: Vec<&(dyn postgres_types::ToSql + Sync)> = pk_params
605 .iter()
606 .map(|p| p.as_ref() as &(dyn postgres_types::ToSql + Sync))
607 .collect();
608
609 let rows = client
610 .execute(delete_sql, &pk_refs)
611 .await
612 .map_err(|error| postgres_dispatched_write_error("DELETE", &error))?;
613
614 Ok(rows as usize)
615 }
616
617 #[cfg(feature = "postgres-sink")]
619 async fn flush_to_client(
620 &mut self,
621 client: &mut tokio_postgres::Client,
622 buffer: &[RecordBatch],
623 ) -> Result<WriteResult, ConnectorError> {
624 let transaction = client.transaction().await.map_err(|error| {
625 ConnectorError::ConnectionFailed(format!("begin PostgreSQL write transaction: {error}"))
626 })?;
627 let mutation = async {
628 schema_resolution::lock_target(
629 &transaction,
630 &self.config,
631 self.target_binding.as_ref(),
632 )
633 .await?;
634 if schema_resolution::defers_constraints(self.target_binding.as_ref()) {
635 transaction
636 .batch_execute("SET CONSTRAINTS ALL DEFERRED")
637 .await
638 .map_err(|error| {
639 ConnectorError::WriteError(format!(
640 "defer PostgreSQL constraints to commit: {error}"
641 ))
642 })?;
643 }
644 match self.config.write_mode {
645 WriteMode::Append => self.flush_append(&transaction, buffer).await,
646 WriteMode::Upsert => self.flush_upsert(&transaction, buffer).await,
647 }
648 }
649 .await;
650 match mutation {
651 Ok(result) => {
652 transaction.commit().await.map_err(|error| {
653 postgres_dispatched_write_error("transaction COMMIT", &error)
654 })?;
655 Ok(result)
656 }
657 Err(error) => {
658 if let Err(rollback) = transaction.rollback().await {
659 tracing::warn!(%rollback, "PostgreSQL write rollback failed; no COMMIT was dispatched");
660 }
661 Err(resolve_uncommitted_transaction_error(error))
662 }
663 }
664 }
665
666 #[cfg(feature = "postgres-sink")]
669 async fn flush_buffer(&mut self) -> Result<WriteResult, ConnectorError> {
670 if self.state == ConnectorState::Failed {
671 return Err(ConnectorError::InvalidState {
672 expected: "unfailed writer; retire and replay from committed progress".into(),
673 actual: "Failed".into(),
674 });
675 }
676 let mut pending = self.take_buffer();
677 if pending.is_empty() {
678 self.buffer = pending;
679 return Ok(WriteResult::new(0, 0));
680 }
681
682 let result = async {
683 let mut client = self
684 .pool()?
685 .get()
686 .await
687 .map_err(|e| ConnectorError::ConnectionFailed(format!("pool checkout: {e}")))?;
688 self.flush_to_client(&mut client, &pending).await
689 }
690 .await;
691
692 pending.clear();
693 self.buffer = pending;
694 if result.is_err() {
695 self.state = ConnectorState::Failed;
696 }
697 result
698 }
699
700 #[cfg(feature = "postgres-sink")]
701 async fn write_batch_with_retained_limit(
702 &mut self,
703 batch: &RecordBatch,
704 retained_limit: usize,
705 ) -> Result<WriteResult, ConnectorError> {
706 if self.state != ConnectorState::Running {
707 return Err(ConnectorError::InvalidState {
708 expected: "Running".into(),
709 actual: self.state.to_string(),
710 });
711 }
712
713 validate_input_batch(batch, &self.schema, &self.config)?;
714
715 if batch.num_rows() == 0 {
716 return Ok(WriteResult::new(0, 0));
717 }
718
719 let batch_retained_bytes = retained_batch_bytes(batch);
720 let preflush = requires_preflush(
721 self.buffered_retained_bytes,
722 batch_retained_bytes,
723 retained_limit,
724 )? || self.pacing.window_full(
725 self.buffered_rows,
726 batch.num_rows(),
727 self.config.statement_timeout,
728 );
729 let result = if preflush {
730 self.flush_buffer().await?
731 } else {
732 WriteResult::new(0, 0)
733 };
734
735 self.retain_batch(batch, batch_retained_bytes);
736 Ok(result)
737 }
738}
739
740#[cfg(feature = "postgres-sink")]
743#[async_trait]
744impl SinkConnector for PostgresSink {
745 fn contract(&self, config: &ConnectorConfig) -> Result<SinkContract, ConnectorError> {
746 let cfg = if config.properties().is_empty() {
747 self.config.clone()
748 } else {
749 Self::decode_connector_config(config)?.0
750 };
751 cfg.validate()?;
752 if config.properties().is_empty() {
753 validate_sink_schema(&self.schema, &cfg)?;
754 }
755 let input_mode = if cfg.changelog_mode {
756 SinkInputMode::FullChangelog
757 } else if cfg.write_mode == WriteMode::Upsert {
758 SinkInputMode::KeyedUpsert
759 } else {
760 SinkInputMode::AppendOnly
761 };
762 let topology = if cfg.write_mode == WriteMode::Append {
767 SinkTopology::MultiWriter
768 } else {
769 SinkTopology::Singleton
770 };
771 Ok(SinkContract::new(
772 SinkConsistency::DurableAtLeastOnce,
773 topology,
774 input_mode,
775 ))
776 }
777
778 fn keyed_mutation_key(
779 &self,
780 config: &ConnectorConfig,
781 ) -> Result<Option<Vec<String>>, ConnectorError> {
782 let cfg = if config.properties().is_empty() {
783 self.config.clone()
784 } else {
785 Self::decode_connector_config(config)?.0
786 };
787 Ok((cfg.changelog_mode && cfg.write_mode == WriteMode::Upsert)
790 .then_some(cfg.primary_key_columns))
791 }
792
793 async fn resolve_schema(
794 &mut self,
795 config: &ConnectorConfig,
796 input: SchemaRef,
797 ) -> Result<crate::schema::resolution::SchemaBinding, ConnectorError> {
798 self.apply_connector_config(config)?;
799 schema_resolution::resolve(config, &self.config, input).await
800 }
801
802 async fn prepare_schema(
803 &mut self,
804 config: &ConnectorConfig,
805 binding: &mut crate::schema::resolution::SchemaBinding,
806 ) -> Result<(), ConnectorError> {
807 schema_resolution::prepare(self, config, binding).await
808 }
809
810 async fn open(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
811 if config.properties().is_empty() {
812 self.config.validate()?;
814 validate_sink_schema(&self.schema, &self.config)?;
815 } else {
816 self.apply_connector_config(config)?;
817 }
818
819 self.prepare_statements()?;
821 let pool = build_pool(&self.config)?;
822 self.state = ConnectorState::Initializing;
823
824 info!(
825 table = %self.config.qualified_table_name(),
826 mode = %self.config.write_mode,
827 "opening PostgreSQL sink connector"
828 );
829
830 let _client = pool.get().await.map_err(|e| {
832 ConnectorError::ConnectionFailed(format!("initial connection failed: {e}"))
833 })?;
834
835 let mut resolved =
836 schema_resolution::resolve(config, &self.config, self.schema.clone()).await?;
837 schema_resolution::verify_binding(config.schema_binding(), &resolved)?;
838 if resolved.value.is_none() {
839 schema_resolution::prepare(self, config, &mut resolved).await?;
840 }
841
842 self.target_binding = Some(resolved);
843 self.pool = Some(pool);
844 self.state = ConnectorState::Running;
845
846 info!(table = %self.config.qualified_table_name(), "PostgreSQL sink connector opened");
847
848 Ok(())
849 }
850
851 async fn write_batch(&mut self, batch: &RecordBatch) -> Result<WriteResult, ConnectorError> {
852 self.write_batch_with_retained_limit(batch, MAX_BUFFERED_RETAINED_BYTES)
853 .await
854 }
855
856 fn schema(&self) -> SchemaRef {
857 self.schema.clone()
858 }
859
860 fn suggested_write_timeout(&self) -> Duration {
861 let statement_budget = if self.config.changelog_mode {
862 self.config.statement_timeout.saturating_mul(2)
863 } else {
864 self.config.statement_timeout
865 };
866 statement_budget + Duration::from_secs(5)
867 }
868
869 fn flush_interval(&self) -> Duration {
870 self.config.flush_interval
871 }
872
873 async fn flush(&mut self) -> Result<(), ConnectorError> {
874 self.flush_buffer().await.map(|_| ())
875 }
876
877 async fn close(&mut self) -> Result<(), ConnectorError> {
878 info!("closing PostgreSQL sink connector");
879
880 let flush_result = self.flush_buffer().await.map(|_| ());
881 self.pool = None;
882 self.state = ConnectorState::Closed;
883
884 info!(
885 table = %self.config.qualified_table_name(),
886 records = self.metrics.records_written.get(),
887 "PostgreSQL sink connector closed"
888 );
889
890 flush_result
891 }
892}
893
894#[cfg(not(feature = "postgres-sink"))]
895#[async_trait]
896impl SinkConnector for PostgresSink {
897 fn contract(&self, _config: &ConnectorConfig) -> Result<SinkContract, ConnectorError> {
898 Err(ConnectorError::ConfigurationError(
899 "PostgreSQL sink requires the 'postgres-sink' feature".into(),
900 ))
901 }
902
903 async fn open(&mut self, _config: &ConnectorConfig) -> Result<(), ConnectorError> {
904 Err(ConnectorError::ConfigurationError(
905 "PostgreSQL sink requires the 'postgres-sink' feature".into(),
906 ))
907 }
908
909 async fn write_batch(&mut self, _batch: &RecordBatch) -> Result<WriteResult, ConnectorError> {
910 Err(ConnectorError::ConfigurationError(
911 "PostgreSQL sink requires the 'postgres-sink' feature".into(),
912 ))
913 }
914
915 fn schema(&self) -> SchemaRef {
916 self.schema.clone()
917 }
918
919 fn suggested_write_timeout(&self) -> Duration {
920 self.config.statement_timeout + Duration::from_secs(5)
921 }
922
923 fn flush_interval(&self) -> Duration {
924 self.config.flush_interval
925 }
926
927 async fn close(&mut self) -> Result<(), ConnectorError> {
928 drop(self.take_buffer());
929 self.state = ConnectorState::Closed;
930 Ok(())
931 }
932}
933
934impl std::fmt::Debug for PostgresSink {
935 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
936 f.debug_struct("PostgresSink")
937 .field("state", &self.state)
938 .field("table", &self.config.qualified_table_name())
939 .field("mode", &self.config.write_mode)
940 .field("buffered_rows", &self.buffered_rows)
941 .field("buffered_retained_bytes", &self.buffered_retained_bytes)
942 .finish_non_exhaustive()
943 }
944}
945
946#[cfg(test)]
947mod tests;