1use std::sync::Arc;
20use std::time::Duration;
21
22use arrow_array::{Array, RecordBatch};
23use arrow_schema::{DataType, Field, Schema, SchemaRef};
24use async_trait::async_trait;
25use tracing::{debug, info};
26
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::{
35 quote_sql_identifier, validate_sql_identifier, PostgresSinkConfig, WriteMode,
36};
37use super::sink_metrics::PostgresSinkMetrics;
38
39#[cfg(feature = "postgres-sink")]
40fn postgres_dispatched_write_error(
41 operation: &str,
42 error: &tokio_postgres::Error,
43) -> ConnectorError {
44 classify_postgres_write_failure(operation, error, error.as_db_error().is_some())
45}
46
47#[cfg(any(feature = "postgres-sink", test))]
48fn classify_postgres_write_failure(
49 operation: &str,
50 error: &dyn std::fmt::Display,
51 server_rejected: bool,
52) -> ConnectorError {
53 if server_rejected {
54 ConnectorError::WriteError(format!("{operation}: {error}"))
55 } else {
56 ConnectorError::outcome_unknown(
57 format!(
58 "PostgreSQL {operation} failed without a server response and may have committed: {error}"
59 ),
60 true,
61 )
62 }
63}
64
65#[cfg(any(feature = "postgres-sink", test))]
66fn resolve_uncommitted_transaction_error(error: ConnectorError) -> ConnectorError {
67 match error {
68 ConnectorError::OutcomeUnknown { message, retryable } if retryable => {
69 ConnectorError::ConnectionFailed(format!(
70 "PostgreSQL transaction mutation failed before COMMIT; no commit was dispatched: {message}"
71 ))
72 }
73 ConnectorError::OutcomeUnknown { message, .. } => ConnectorError::TransactionError(
74 format!(
75 "PostgreSQL transaction mutation failed before COMMIT; no commit was dispatched: {message}"
76 ),
77 ),
78 error => error,
79 }
80}
81use super::types::{
82 arrow_to_pg_ddl_type, arrow_type_to_pg_array_cast, arrow_type_to_pg_sql, postgres_type,
83};
84
85#[cfg(feature = "postgres-sink")]
86use super::types::{arrow_column_to_pg_array, postgres_copy_batch, validate_postgres_array_values};
87#[cfg(feature = "postgres-sink")]
88use bytes::BytesMut;
89#[cfg(feature = "postgres-sink")]
90use deadpool_postgres::Pool;
91
92#[cfg(feature = "postgres-sink")]
96const MAX_BUFFERED_RETAINED_BYTES: usize = 16 * 1024 * 1024;
97#[cfg(feature = "postgres-sink")]
98const COPY_ENCODE_INITIAL_CAPACITY: usize = 64 * 1024;
99#[cfg(feature = "postgres-sink")]
100const SINK_POOL_SIZE: usize = 1;
101
102const CHANGELOG_METADATA_COLUMNS: &[&str] = &["_op", "_ts_ms", "__weight"];
103
104#[cfg(feature = "postgres-sink")]
105fn build_pool(config: &PostgresSinkConfig) -> Result<Pool, ConnectorError> {
106 config.validate()?;
107
108 let mut pool_config = deadpool_postgres::Config::new();
109 pool_config.host = Some(config.hostname.clone());
110 pool_config.port = Some(config.port);
111 pool_config.dbname = Some(config.database.clone());
112 pool_config.user = Some(config.username.clone());
113 pool_config.password = Some(config.password.clone());
114 pool_config.options = Some(config.statement_timeout_startup_option());
115 pool_config.connect_timeout = Some(config.connect_timeout);
116 pool_config.ssl_mode = Some(match config.ssl_mode {
117 crate::postgres::SslMode::Disable => deadpool_postgres::SslMode::Disable,
118 crate::postgres::SslMode::VerifyFull => deadpool_postgres::SslMode::Require,
119 });
120 let mut deadpool_config = deadpool_postgres::PoolConfig::new(SINK_POOL_SIZE);
121 deadpool_config.timeouts.wait = Some(config.connect_timeout);
122 deadpool_config.timeouts.create = Some(config.connect_timeout);
123 pool_config.pool = Some(deadpool_config);
124
125 let runtime = Some(deadpool_postgres::Runtime::Tokio1);
126 match config.ssl_mode {
127 crate::postgres::SslMode::Disable => pool_config
128 .create_pool(runtime, tokio_postgres::NoTls)
129 .map_err(|error| {
130 ConnectorError::ConnectionFailed(format!("pool creation failed: {error}"))
131 }),
132 crate::postgres::SslMode::VerifyFull => {
133 let tls = crate::postgres::make_rustls_connector(config.ssl_ca_cert_path.as_deref())?;
134 pool_config.create_pool(runtime, tls).map_err(|error| {
135 ConnectorError::ConnectionFailed(format!("TLS pool creation failed: {error}"))
136 })
137 }
138 }
139}
140
141pub struct PostgresSink {
146 config: PostgresSinkConfig,
148 schema: SchemaRef,
150 user_schema: SchemaRef,
152 state: ConnectorState,
154 buffer: Vec<RecordBatch>,
156 buffered_rows: usize,
158 buffered_retained_bytes: usize,
160 metrics: PostgresSinkMetrics,
162 upsert_sql: Option<String>,
164 copy_sql: Option<String>,
166 create_table_sql: Option<String>,
168 delete_sql: Option<String>,
170 #[cfg(feature = "postgres-sink")]
172 pool: Option<Pool>,
173}
174
175impl PostgresSink {
176 #[must_use]
178 pub fn new(
179 schema: SchemaRef,
180 config: PostgresSinkConfig,
181 registry: Option<&prometheus::Registry>,
182 ) -> Self {
183 let user_schema = build_user_schema(&schema);
184 Self {
185 config,
186 schema,
187 user_schema,
188 state: ConnectorState::Created,
189 buffer: Vec::with_capacity(4),
190 buffered_rows: 0,
191 buffered_retained_bytes: 0,
192 metrics: PostgresSinkMetrics::new(registry),
193 upsert_sql: None,
194 copy_sql: None,
195 create_table_sql: None,
196 delete_sql: None,
197 #[cfg(feature = "postgres-sink")]
198 pool: None,
199 }
200 }
201
202 pub(crate) fn from_connector_config(
206 config: &ConnectorConfig,
207 registry: Option<&prometheus::Registry>,
208 ) -> Result<Self, ConnectorError> {
209 let (sink_config, schema) = Self::decode_connector_config(config)?;
210 Ok(Self::new(schema, sink_config, registry))
211 }
212
213 fn decode_connector_config(
214 config: &ConnectorConfig,
215 ) -> Result<(PostgresSinkConfig, SchemaRef), ConnectorError> {
216 let sink_config = PostgresSinkConfig::from_config(config)?;
217 if config.get("_arrow_schema").is_none() {
218 return Err(ConnectorError::ConfigurationError(
219 "PostgreSQL sink requires the engine-injected '_arrow_schema'".into(),
220 ));
221 }
222 let schema = config.arrow_schema().ok_or_else(|| {
223 ConnectorError::ConfigurationError(
224 "invalid PostgreSQL sink '_arrow_schema' encoding".into(),
225 )
226 })?;
227 validate_sink_schema(&schema, &sink_config)?;
228 Ok((sink_config, schema))
229 }
230
231 fn apply_connector_config(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
232 let (sink_config, schema) = Self::decode_connector_config(config)?;
233 let user_schema = build_user_schema(&schema);
234 self.config = sink_config;
235 self.schema = schema;
236 self.user_schema = user_schema;
237 Ok(())
238 }
239
240 #[must_use]
242 pub fn state(&self) -> ConnectorState {
243 self.state
244 }
245
246 #[must_use]
248 pub fn buffered_rows(&self) -> usize {
249 self.buffered_rows
250 }
251
252 #[cfg(feature = "postgres-sink")]
253 fn retain_batch(&mut self, batch: &RecordBatch, retained_bytes: usize) {
254 self.buffer.push(batch.clone());
255 self.buffered_rows = self.buffered_rows.saturating_add(batch.num_rows());
256 self.buffered_retained_bytes = self.buffered_retained_bytes.saturating_add(retained_bytes);
257 }
258
259 fn take_buffer(&mut self) -> Vec<RecordBatch> {
262 self.buffered_rows = 0;
263 self.buffered_retained_bytes = 0;
264 std::mem::take(&mut self.buffer)
265 }
266
267 #[must_use]
269 pub fn sink_metrics(&self) -> &PostgresSinkMetrics {
270 &self.metrics
271 }
272
273 pub fn build_copy_sql(
285 schema: &SchemaRef,
286 config: &PostgresSinkConfig,
287 ) -> Result<String, ConnectorError> {
288 validate_sink_schema(schema, config)?;
289 let columns = quoted_user_columns(schema);
290 Ok(format!(
291 "COPY {} ({}) FROM STDIN BINARY",
292 config.qualified_table_name(),
293 columns.join(", "),
294 ))
295 }
296
297 pub fn build_upsert_sql(
311 schema: &SchemaRef,
312 config: &PostgresSinkConfig,
313 ) -> Result<String, ConnectorError> {
314 validate_sink_schema(schema, config)?;
315 let fields = user_fields(schema);
316
317 let columns: Vec<String> = fields
318 .iter()
319 .map(|field| quote_sql_identifier(field.name()))
320 .collect();
321
322 let unnest_params: Vec<String> = fields
323 .iter()
324 .enumerate()
325 .map(|(i, field)| arrow_type_to_pg_array_cast(field.data_type(), i + 1))
326 .collect::<Result<_, _>>()?;
327
328 let non_key_columns: Vec<&Arc<Field>> = fields
329 .iter()
330 .copied()
331 .filter(|field| {
332 !config
333 .primary_key_columns
334 .iter()
335 .any(|primary_key| primary_key == field.name())
336 })
337 .collect();
338
339 let update_clause: Vec<String> = non_key_columns
340 .iter()
341 .map(|field| {
342 let column = quote_sql_identifier(field.name());
343 format!("{column} = EXCLUDED.{column}")
344 })
345 .collect();
346
347 let pk_list = config
348 .primary_key_columns
349 .iter()
350 .map(|column| quote_sql_identifier(column))
351 .collect::<Vec<_>>()
352 .join(", ");
353
354 if update_clause.is_empty() {
355 Ok(format!(
357 "INSERT INTO {} ({}) \
358 SELECT * FROM UNNEST({}) \
359 ON CONFLICT ({}) DO NOTHING",
360 config.qualified_table_name(),
361 columns.join(", "),
362 unnest_params.join(", "),
363 pk_list,
364 ))
365 } else {
366 Ok(format!(
367 "INSERT INTO {} ({}) \
368 SELECT * FROM UNNEST({}) \
369 ON CONFLICT ({}) DO UPDATE SET {}",
370 config.qualified_table_name(),
371 columns.join(", "),
372 unnest_params.join(", "),
373 pk_list,
374 update_clause.join(", "),
375 ))
376 }
377 }
378
379 pub fn build_delete_sql(
394 schema: &SchemaRef,
395 config: &PostgresSinkConfig,
396 ) -> Result<String, ConnectorError> {
397 validate_sink_schema(schema, config)?;
398 let pg_type = |column: &str| -> Result<&'static str, ConnectorError> {
399 let field = schema.field_with_name(column).map_err(|_| {
400 ConnectorError::ConfigurationError(format!(
401 "primary key column '{column}' is not present in PostgreSQL sink schema"
402 ))
403 })?;
404 arrow_type_to_pg_sql(field.data_type())
405 };
406 let pk = &config.primary_key_columns;
407
408 if pk.len() <= 1 {
412 let column = pk.first().ok_or_else(|| {
413 ConnectorError::ConfigurationError(
414 "PostgreSQL changelog delete requires a primary key".into(),
415 )
416 })?;
417 let quoted_column = quote_sql_identifier(column);
418 Ok(format!(
419 "DELETE FROM {} WHERE {quoted_column} = ANY($1::{}[])",
420 config.qualified_table_name(),
421 pg_type(column)?,
422 ))
423 } else {
424 let unnest_args: Vec<String> = pk
425 .iter()
426 .enumerate()
427 .map(|(i, column)| Ok(format!("${}::{}[]", i + 1, pg_type(column)?)))
428 .collect::<Result<_, ConnectorError>>()?;
429 let quoted_keys: Vec<String> = pk
430 .iter()
431 .map(|column| quote_sql_identifier(column))
432 .collect();
433 let target_alias = quote_sql_identifier("target");
434 let key_alias = quote_sql_identifier("keys");
435 let match_conditions: Vec<String> = quoted_keys
436 .iter()
437 .map(|column| format!("{target_alias}.{column} = {key_alias}.{column}"))
438 .collect();
439 Ok(format!(
440 "DELETE FROM {} AS {target_alias} USING UNNEST({}) AS {key_alias}({}) WHERE {}",
441 config.qualified_table_name(),
442 unnest_args.join(", "),
443 quoted_keys.join(", "),
444 match_conditions.join(" AND "),
445 ))
446 }
447 }
448
449 pub fn build_create_table_sql(
464 schema: &SchemaRef,
465 config: &PostgresSinkConfig,
466 ) -> Result<String, ConnectorError> {
467 validate_sink_schema(schema, config)?;
468 let fields = user_fields(schema);
469
470 let column_defs: Vec<String> = fields
471 .iter()
472 .map(|field| {
473 let pg_type = arrow_to_pg_ddl_type(field.data_type())?;
474 let nullable = if field.is_nullable() { "" } else { " NOT NULL" };
475 Ok(format!(
476 " {} {}{}",
477 quote_sql_identifier(field.name()),
478 pg_type,
479 nullable
480 ))
481 })
482 .collect::<Result<_, ConnectorError>>()?;
483
484 let mut ddl = format!(
485 "CREATE TABLE IF NOT EXISTS {} (\n{}\n",
486 config.qualified_table_name(),
487 column_defs.join(",\n"),
488 );
489
490 if !config.primary_key_columns.is_empty() {
491 use std::fmt::Write;
492 let primary_keys = config
493 .primary_key_columns
494 .iter()
495 .map(|column| quote_sql_identifier(column))
496 .collect::<Vec<_>>()
497 .join(", ");
498 let _ = write!(ddl, ",\n PRIMARY KEY ({primary_keys})\n");
499 }
500
501 ddl.push(')');
502 Ok(ddl)
503 }
504
505 pub fn split_changelog_batch(
520 batch: &RecordBatch,
521 ) -> Result<(RecordBatch, RecordBatch), ConnectorError> {
522 let op_idx = batch.schema().index_of("_op").map_err(|_| {
523 ConnectorError::ConfigurationError(
524 "changelog mode requires '_op' column in input schema".into(),
525 )
526 })?;
527
528 let op_array = batch
529 .column(op_idx)
530 .as_any()
531 .downcast_ref::<arrow_array::StringArray>()
532 .ok_or_else(|| {
533 ConnectorError::ConfigurationError("'_op' column must be String (Utf8) type".into())
534 })?;
535
536 validate_operation_values(op_array)?;
537
538 let mut insert_indices = Vec::new();
539 let mut delete_indices = Vec::new();
540
541 for i in 0..op_array.len() {
542 match op_array.value(i) {
543 "I" | "U" | "U+" | "R" | "r" => {
544 insert_indices.push(u32::try_from(i).map_err(|_| {
545 ConnectorError::Internal(
546 "PostgreSQL changelog batch exceeds UInt32 row indexing".into(),
547 )
548 })?);
549 }
550 "D" | "U-" => {
551 delete_indices.push(u32::try_from(i).map_err(|_| {
552 ConnectorError::Internal(
553 "PostgreSQL changelog batch exceeds UInt32 row indexing".into(),
554 )
555 })?);
556 }
557 _ => unreachable!("operation values validated above"),
558 }
559 }
560
561 let insert_batch = filter_batch_by_indices(batch, &insert_indices)?;
562 let delete_batch = filter_batch_by_indices(batch, &delete_indices)?;
563
564 Ok((insert_batch, delete_batch))
565 }
566
567 fn prepare_statements(&mut self) -> Result<(), ConnectorError> {
571 self.copy_sql = (self.config.write_mode == WriteMode::Append)
572 .then(|| Self::build_copy_sql(&self.schema, &self.config))
573 .transpose()?;
574 self.upsert_sql = (self.config.write_mode == WriteMode::Upsert)
575 .then(|| Self::build_upsert_sql(&self.schema, &self.config))
576 .transpose()?;
577 self.create_table_sql = self
578 .config
579 .auto_create_table
580 .then(|| Self::build_create_table_sql(&self.schema, &self.config))
581 .transpose()?;
582 self.delete_sql = self
583 .config
584 .changelog_mode
585 .then(|| Self::build_delete_sql(&self.schema, &self.config))
586 .transpose()?;
587 Ok(())
588 }
589
590 #[cfg(feature = "postgres-sink")]
592 fn pool(&self) -> Result<&Pool, ConnectorError> {
593 self.pool.as_ref().ok_or(ConnectorError::InvalidState {
594 expected: "pool initialized (call open() first)".into(),
595 actual: "pool not initialized".into(),
596 })
597 }
598
599 #[cfg(feature = "postgres-sink")]
601 fn concat_buffer(&self, buffer: &[RecordBatch]) -> Result<RecordBatch, ConnectorError> {
602 if buffer.is_empty() {
603 return Ok(RecordBatch::new_empty(self.user_schema.clone()));
604 }
605
606 let stripped: Result<Vec<RecordBatch>, ConnectorError> =
607 buffer.iter().map(strip_metadata_columns).collect();
608 let stripped = stripped?;
609
610 arrow_select::concat::concat_batches(&self.user_schema, &stripped)
611 .map_err(|e| ConnectorError::Internal(format!("batch concat failed: {e}")))
612 }
613
614 #[cfg(feature = "postgres-sink")]
616 async fn flush_append(
617 &mut self,
618 client: &tokio_postgres::Client,
619 buffer: &[RecordBatch],
620 ) -> Result<WriteResult, ConnectorError> {
621 let user_batch = self.concat_buffer(buffer)?;
622 if user_batch.num_rows() == 0 {
623 return Ok(WriteResult::new(0, 0));
624 }
625 let copy_batch = postgres_copy_batch(&user_batch)?;
626 let mut encoder = pgpq::ArrowToPostgresBinaryEncoder::try_new(copy_batch.schema().as_ref())
627 .map_err(|e| ConnectorError::Internal(format!("pgpq encoder init: {e}")))?;
628
629 let mut encode_buf = BytesMut::with_capacity(COPY_ENCODE_INITIAL_CAPACITY);
632 encoder.write_header(&mut encode_buf);
633 encoder
634 .write_batch(©_batch, &mut encode_buf)
635 .map_err(|e| ConnectorError::Internal(format!("pgpq encode: {e}")))?;
636 encoder
637 .write_footer(&mut encode_buf)
638 .map_err(|e| ConnectorError::Internal(format!("pgpq footer: {e}")))?;
639
640 let encoded_bytes = encode_buf.len();
641 let bytes_to_send = encode_buf.freeze();
642
643 let copy_sql = self
644 .copy_sql
645 .as_deref()
646 .ok_or_else(|| ConnectorError::Internal("COPY SQL not prepared".into()))?;
647
648 let sink = client
649 .copy_in(copy_sql)
650 .await
651 .map_err(|error| postgres_dispatched_write_error("COPY start", &error))?;
652
653 {
654 use futures_util::SinkExt;
655 futures_util::pin_mut!(sink);
656 sink.send(bytes_to_send)
657 .await
658 .map_err(|error| postgres_dispatched_write_error("COPY send", &error))?;
659 sink.close()
660 .await
661 .map_err(|error| postgres_dispatched_write_error("COPY finish", &error))?;
662 }
663
664 let rows = user_batch.num_rows();
665 self.metrics.record_write(rows as u64, encoded_bytes as u64);
666 self.metrics.record_flush();
667 self.metrics.record_copy();
668
669 Ok(WriteResult::new(rows, encoded_bytes as u64))
670 }
671
672 #[cfg(feature = "postgres-sink")]
674 #[allow(clippy::cast_possible_truncation)]
675 async fn flush_upsert(
676 &mut self,
677 client: &mut tokio_postgres::Client,
678 buffer: &[RecordBatch],
679 ) -> Result<WriteResult, ConnectorError> {
680 if self.config.changelog_mode {
681 return self.flush_changelog(client, buffer).await;
682 }
683
684 let user_batch = self.concat_buffer(buffer)?;
685 if user_batch.num_rows() == 0 {
686 return Ok(WriteResult::new(0, 0));
687 }
688 let user_batch = collapse_upsert_batch(&user_batch, &self.config.primary_key_columns)?;
689
690 let upsert_sql = self
691 .upsert_sql
692 .as_deref()
693 .ok_or_else(|| ConnectorError::Internal("upsert SQL not prepared".into()))?;
694
695 let rows = execute_unnest(client, upsert_sql, &user_batch).await?;
696
697 let byte_estimate = retained_batch_bytes_u64(&user_batch);
698 self.metrics.record_write(rows, byte_estimate);
699 self.metrics.record_flush();
700 self.metrics.record_upsert();
701
702 Ok(WriteResult::new(rows as usize, byte_estimate))
703 }
704
705 #[cfg(feature = "postgres-sink")]
708 #[allow(clippy::cast_possible_truncation)]
709 async fn flush_changelog(
710 &mut self,
711 client: &mut tokio_postgres::Client,
712 buffer: &[RecordBatch],
713 ) -> Result<WriteResult, ConnectorError> {
714 if buffer.is_empty() {
715 return Ok(WriteResult::new(0, 0));
716 }
717
718 for batch in buffer {
721 validate_changelog_input(batch)?;
722 }
723
724 let split_input: Vec<RecordBatch> = {
733 let schema = buffer[0].schema();
734 let combined = arrow_select::concat::concat_batches(&schema, buffer)
735 .map_err(|e| ConnectorError::Internal(format!("concat changelog: {e}")))?;
736 vec![collapse_changelog(
737 &combined,
738 &self.config.primary_key_columns,
739 )?]
740 };
741
742 let mut all_inserts = Vec::new();
744 let mut all_deletes = Vec::new();
745 for batch in &split_input {
746 let (ins, del) = Self::split_changelog_batch(batch)?;
747 if ins.num_rows() > 0 {
748 all_inserts.push(ins);
749 }
750 if del.num_rows() > 0 {
751 all_deletes.push(del);
752 }
753 }
754
755 let transaction = client.transaction().await.map_err(|error| {
756 ConnectorError::ConnectionFailed(format!(
757 "begin PostgreSQL changelog transaction: {error}"
758 ))
759 })?;
760 let mutation = async {
761 let mut upserted = 0_u64;
762 let mut deleted = 0_usize;
763 let mut bytes = 0_u64;
764
765 if !all_inserts.is_empty() {
766 let insert_batch =
767 arrow_select::concat::concat_batches(&self.user_schema, &all_inserts)
768 .map_err(|e| ConnectorError::Internal(format!("concat inserts: {e}")))?;
769 let upsert_sql = self
770 .upsert_sql
771 .as_deref()
772 .ok_or_else(|| ConnectorError::Internal("upsert SQL not prepared".into()))?;
773 upserted = execute_unnest(&transaction, upsert_sql, &insert_batch).await?;
774 bytes = retained_batch_bytes_u64(&insert_batch);
775 }
776
777 if !all_deletes.is_empty() {
778 let delete_batch =
779 arrow_select::concat::concat_batches(&self.user_schema, &all_deletes)
780 .map_err(|e| ConnectorError::Internal(format!("concat deletes: {e}")))?;
781 bytes = bytes.saturating_add(retained_batch_bytes_u64(&delete_batch));
782 deleted = self.execute_deletes(&transaction, &delete_batch).await?;
783 }
784
785 Ok::<_, ConnectorError>((upserted, deleted, bytes))
786 }
787 .await;
788
789 let (upserted, deleted, total_bytes) = match mutation {
790 Ok(result) => {
791 transaction.commit().await.map_err(|error| {
792 postgres_dispatched_write_error("transaction COMMIT", &error)
793 })?;
794 result
795 }
796 Err(error) => {
797 if let Err(rollback_error) = transaction.rollback().await {
798 tracing::warn!(
799 %rollback_error,
800 "PostgreSQL changelog rollback failed after a mutation error; no COMMIT was dispatched"
801 );
802 }
803 return Err(resolve_uncommitted_transaction_error(error));
804 }
805 };
806
807 let total_rows = upserted.saturating_add(deleted as u64);
808 if total_rows != 0 {
809 self.metrics.record_write(total_rows, total_bytes);
810 }
811 if upserted != 0 {
812 self.metrics.record_upsert();
813 }
814 if deleted != 0 {
815 self.metrics.record_deletes(deleted as u64);
816 }
817 self.metrics.record_flush();
818 Ok(WriteResult::new(total_rows as usize, total_bytes))
819 }
820
821 #[cfg(feature = "postgres-sink")]
823 #[allow(clippy::cast_possible_truncation)]
824 async fn execute_deletes<C>(
825 &self,
826 client: &C,
827 delete_batch: &RecordBatch,
828 ) -> Result<usize, ConnectorError>
829 where
830 C: tokio_postgres::GenericClient + Sync,
831 {
832 if delete_batch.num_rows() == 0 {
833 return Ok(0);
834 }
835
836 let delete_sql = self
837 .delete_sql
838 .as_deref()
839 .ok_or_else(|| ConnectorError::Internal("DELETE SQL not prepared".into()))?;
840
841 let pk_params: Vec<Box<dyn postgres_types::ToSql + Sync + Send>> = self
842 .config
843 .primary_key_columns
844 .iter()
845 .map(|col| {
846 let idx = delete_batch.schema().index_of(col).map_err(|_| {
847 ConnectorError::ConfigurationError(format!(
848 "primary key column '{col}' not in delete batch"
849 ))
850 })?;
851 arrow_column_to_pg_array(delete_batch.column(idx))
852 })
853 .collect::<Result<_, _>>()?;
854
855 let pk_refs: Vec<&(dyn postgres_types::ToSql + Sync)> = pk_params
856 .iter()
857 .map(|p| p.as_ref() as &(dyn postgres_types::ToSql + Sync))
858 .collect();
859
860 let rows = client
861 .execute(delete_sql, &pk_refs)
862 .await
863 .map_err(|error| postgres_dispatched_write_error("DELETE", &error))?;
864
865 Ok(rows as usize)
866 }
867
868 #[cfg(feature = "postgres-sink")]
870 async fn flush_to_client(
871 &mut self,
872 client: &mut tokio_postgres::Client,
873 buffer: &[RecordBatch],
874 ) -> Result<WriteResult, ConnectorError> {
875 match self.config.write_mode {
876 WriteMode::Append => self.flush_append(client, buffer).await,
877 WriteMode::Upsert => self.flush_upsert(client, buffer).await,
878 }
879 }
880
881 #[cfg(feature = "postgres-sink")]
884 async fn flush_buffer(&mut self) -> Result<WriteResult, ConnectorError> {
885 let mut pending = self.take_buffer();
886 if pending.is_empty() {
887 self.buffer = pending;
888 return Ok(WriteResult::new(0, 0));
889 }
890
891 let result = async {
892 let mut client = self
893 .pool()?
894 .get()
895 .await
896 .map_err(|e| ConnectorError::ConnectionFailed(format!("pool checkout: {e}")))?;
897 self.flush_to_client(&mut client, &pending).await
898 }
899 .await;
900
901 pending.clear();
902 self.buffer = pending;
903 result
904 }
905
906 #[cfg(feature = "postgres-sink")]
907 async fn write_batch_with_retained_limit(
908 &mut self,
909 batch: &RecordBatch,
910 retained_limit: usize,
911 ) -> Result<WriteResult, ConnectorError> {
912 if self.state != ConnectorState::Running {
913 return Err(ConnectorError::InvalidState {
914 expected: "Running".into(),
915 actual: self.state.to_string(),
916 });
917 }
918
919 validate_input_batch(batch, &self.schema, &self.config)?;
920
921 if batch.num_rows() == 0 {
922 return Ok(WriteResult::new(0, 0));
923 }
924
925 let batch_retained_bytes = retained_batch_bytes(batch);
926 let preflush = requires_preflush(
927 self.buffered_retained_bytes,
928 batch_retained_bytes,
929 retained_limit,
930 )?;
931 let result = if preflush {
932 self.flush_buffer().await?
933 } else {
934 WriteResult::new(0, 0)
935 };
936
937 self.retain_batch(batch, batch_retained_bytes);
938 Ok(result)
939 }
940}
941
942#[cfg(feature = "postgres-sink")]
945#[async_trait]
946impl SinkConnector for PostgresSink {
947 fn contract(&self, config: &ConnectorConfig) -> Result<SinkContract, ConnectorError> {
948 let cfg = if config.properties().is_empty() {
949 self.config.clone()
950 } else {
951 Self::decode_connector_config(config)?.0
952 };
953 cfg.validate()?;
954 if config.properties().is_empty() {
955 validate_sink_schema(&self.schema, &cfg)?;
956 }
957 let input_mode = if cfg.changelog_mode {
958 SinkInputMode::FullChangelog
959 } else if cfg.write_mode == WriteMode::Upsert {
960 SinkInputMode::KeyedUpsert
961 } else {
962 SinkInputMode::AppendOnly
963 };
964 let topology = if cfg.write_mode == WriteMode::Append {
969 SinkTopology::MultiWriter
970 } else {
971 SinkTopology::Singleton
972 };
973 Ok(SinkContract::new(
974 SinkConsistency::DurableAtLeastOnce,
975 topology,
976 input_mode,
977 ))
978 }
979
980 async fn open(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
981 if config.properties().is_empty() {
982 self.config.validate()?;
984 validate_sink_schema(&self.schema, &self.config)?;
985 } else {
986 self.apply_connector_config(config)?;
987 }
988
989 self.prepare_statements()?;
991 let pool = build_pool(&self.config)?;
992 self.state = ConnectorState::Initializing;
993
994 info!(
995 table = %self.config.qualified_table_name(),
996 mode = %self.config.write_mode,
997 "opening PostgreSQL sink connector"
998 );
999
1000 let client = pool.get().await.map_err(|e| {
1002 ConnectorError::ConnectionFailed(format!("initial connection failed: {e}"))
1003 })?;
1004
1005 if self.config.auto_create_table {
1007 if let Some(ddl) = &self.create_table_sql {
1008 client.batch_execute(ddl.as_str()).await.map_err(|e| {
1009 ConnectorError::Internal(format!("auto-create table failed: {e}"))
1010 })?;
1011 debug!(table = %self.config.qualified_table_name(), "target table ensured");
1012 }
1013 }
1014
1015 self.pool = Some(pool);
1016 self.state = ConnectorState::Running;
1017
1018 info!(table = %self.config.qualified_table_name(), "PostgreSQL sink connector opened");
1019
1020 Ok(())
1021 }
1022
1023 async fn write_batch(&mut self, batch: &RecordBatch) -> Result<WriteResult, ConnectorError> {
1024 self.write_batch_with_retained_limit(batch, MAX_BUFFERED_RETAINED_BYTES)
1025 .await
1026 }
1027
1028 fn schema(&self) -> SchemaRef {
1029 self.schema.clone()
1030 }
1031
1032 fn suggested_write_timeout(&self) -> Duration {
1033 let statement_budget = if self.config.changelog_mode {
1034 self.config.statement_timeout.saturating_mul(2)
1035 } else {
1036 self.config.statement_timeout
1037 };
1038 statement_budget + Duration::from_secs(5)
1039 }
1040
1041 fn flush_interval(&self) -> Duration {
1042 self.config.flush_interval
1043 }
1044
1045 async fn flush(&mut self) -> Result<(), ConnectorError> {
1046 self.flush_buffer().await.map(|_| ())
1047 }
1048
1049 async fn close(&mut self) -> Result<(), ConnectorError> {
1050 info!("closing PostgreSQL sink connector");
1051
1052 let flush_result = self.flush_buffer().await.map(|_| ());
1053 self.pool = None;
1054 self.state = ConnectorState::Closed;
1055
1056 info!(
1057 table = %self.config.qualified_table_name(),
1058 records = self.metrics.records_written.get(),
1059 "PostgreSQL sink connector closed"
1060 );
1061
1062 flush_result
1063 }
1064}
1065
1066#[cfg(not(feature = "postgres-sink"))]
1067#[async_trait]
1068impl SinkConnector for PostgresSink {
1069 fn contract(&self, _config: &ConnectorConfig) -> Result<SinkContract, ConnectorError> {
1070 Err(ConnectorError::ConfigurationError(
1071 "PostgreSQL sink requires the 'postgres-sink' feature".into(),
1072 ))
1073 }
1074
1075 async fn open(&mut self, _config: &ConnectorConfig) -> Result<(), ConnectorError> {
1076 Err(ConnectorError::ConfigurationError(
1077 "PostgreSQL sink requires the 'postgres-sink' feature".into(),
1078 ))
1079 }
1080
1081 async fn write_batch(&mut self, _batch: &RecordBatch) -> Result<WriteResult, ConnectorError> {
1082 Err(ConnectorError::ConfigurationError(
1083 "PostgreSQL sink requires the 'postgres-sink' feature".into(),
1084 ))
1085 }
1086
1087 fn schema(&self) -> SchemaRef {
1088 self.schema.clone()
1089 }
1090
1091 fn suggested_write_timeout(&self) -> Duration {
1092 self.config.statement_timeout + Duration::from_secs(5)
1093 }
1094
1095 fn flush_interval(&self) -> Duration {
1096 self.config.flush_interval
1097 }
1098
1099 async fn close(&mut self) -> Result<(), ConnectorError> {
1100 drop(self.take_buffer());
1101 self.state = ConnectorState::Closed;
1102 Ok(())
1103 }
1104}
1105
1106impl std::fmt::Debug for PostgresSink {
1107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1108 f.debug_struct("PostgresSink")
1109 .field("state", &self.state)
1110 .field("table", &self.config.qualified_table_name())
1111 .field("mode", &self.config.write_mode)
1112 .field("buffered_rows", &self.buffered_rows)
1113 .field("buffered_retained_bytes", &self.buffered_retained_bytes)
1114 .finish_non_exhaustive()
1115 }
1116}
1117
1118fn is_changelog_metadata(name: &str) -> bool {
1121 CHANGELOG_METADATA_COLUMNS.contains(&name)
1122}
1123
1124fn quoted_user_columns(schema: &SchemaRef) -> Vec<String> {
1125 user_fields(schema)
1126 .iter()
1127 .map(|field| quote_sql_identifier(field.name()))
1128 .collect()
1129}
1130
1131fn user_fields(schema: &SchemaRef) -> Vec<&Arc<Field>> {
1133 schema
1134 .fields()
1135 .iter()
1136 .filter(|field| !is_changelog_metadata(field.name()))
1137 .collect()
1138}
1139
1140fn build_user_schema(schema: &SchemaRef) -> SchemaRef {
1142 Arc::new(Schema::new(
1143 schema
1144 .fields()
1145 .iter()
1146 .filter(|field| !is_changelog_metadata(field.name()))
1147 .cloned()
1148 .collect::<Vec<_>>(),
1149 ))
1150}
1151
1152fn validate_sink_schema(
1153 schema: &SchemaRef,
1154 config: &PostgresSinkConfig,
1155) -> Result<(), ConnectorError> {
1156 config.validate()?;
1157 let fields = user_fields(schema);
1158 if fields.is_empty() {
1159 return Err(ConnectorError::SchemaMismatch(
1160 "PostgreSQL sink schema has no writable columns".into(),
1161 ));
1162 }
1163
1164 let mut names = std::collections::HashSet::new();
1165 for field in schema.fields() {
1166 validate_sql_identifier(field.name(), "PostgreSQL sink column name")?;
1167 if !names.insert(field.name()) {
1168 return Err(ConnectorError::ConfigurationError(format!(
1169 "PostgreSQL sink schema contains duplicate column '{}'",
1170 field.name()
1171 )));
1172 }
1173 if !is_changelog_metadata(field.name()) {
1174 postgres_type(field.data_type()).map_err(|error| {
1175 ConnectorError::ConfigurationError(format!(
1176 "PostgreSQL sink column '{}': {error}",
1177 field.name()
1178 ))
1179 })?;
1180 }
1181 }
1182
1183 let op = schema.field_with_name("_op").ok();
1184 let weight = schema.field_with_name("__weight").ok();
1185 let timestamp = schema.field_with_name("_ts_ms").ok();
1186 if config.changelog_mode {
1187 if op.is_none() && weight.is_none() {
1188 return Err(ConnectorError::ConfigurationError(
1189 "PostgreSQL changelog mode requires an Utf8 '_op' or Int64 '__weight' column"
1190 .into(),
1191 ));
1192 }
1193 } else if op.is_some() || weight.is_some() || timestamp.is_some() {
1194 return Err(ConnectorError::ConfigurationError(
1195 "PostgreSQL sink '_op', '_ts_ms', and '__weight' columns require changelog.mode=true"
1196 .into(),
1197 ));
1198 }
1199 if let Some(field) = op {
1200 if field.data_type() != &DataType::Utf8 || field.is_nullable() {
1201 return Err(ConnectorError::ConfigurationError(
1202 "PostgreSQL changelog '_op' must be non-null Utf8".into(),
1203 ));
1204 }
1205 }
1206 if let Some(field) = weight {
1207 if field.data_type() != &DataType::Int64 || field.is_nullable() {
1208 return Err(ConnectorError::ConfigurationError(
1209 "PostgreSQL changelog '__weight' must be non-null Int64".into(),
1210 ));
1211 }
1212 }
1213
1214 for primary_key in &config.primary_key_columns {
1215 let field = schema.field_with_name(primary_key).map_err(|_| {
1216 ConnectorError::ConfigurationError(format!(
1217 "primary key column '{primary_key}' is not present in PostgreSQL sink schema"
1218 ))
1219 })?;
1220 if is_changelog_metadata(primary_key) {
1221 return Err(ConnectorError::ConfigurationError(format!(
1222 "primary key column '{primary_key}' is reserved changelog metadata"
1223 )));
1224 }
1225 if field.is_nullable() {
1226 return Err(ConnectorError::ConfigurationError(format!(
1227 "primary key column '{primary_key}' must be non-nullable"
1228 )));
1229 }
1230 }
1231 Ok(())
1232}
1233
1234#[cfg(feature = "postgres-sink")]
1235fn validate_input_batch(
1236 batch: &RecordBatch,
1237 expected_schema: &SchemaRef,
1238 config: &PostgresSinkConfig,
1239) -> Result<(), ConnectorError> {
1240 if batch.schema().as_ref() != expected_schema.as_ref() {
1241 return Err(ConnectorError::SchemaMismatch(
1242 "PostgreSQL sink batch schema does not match its admitted Arrow schema".into(),
1243 ));
1244 }
1245 for (field, column) in batch.schema().fields().iter().zip(batch.columns()) {
1246 if !is_changelog_metadata(field.name()) {
1247 validate_postgres_array_values(column.as_ref())?;
1248 }
1249 }
1250 for primary_key in &config.primary_key_columns {
1251 let index = batch.schema().index_of(primary_key).map_err(|_| {
1252 ConnectorError::SchemaMismatch(format!(
1253 "primary key column '{primary_key}' is absent from PostgreSQL sink batch"
1254 ))
1255 })?;
1256 if batch.column(index).null_count() != 0 {
1257 return Err(ConnectorError::SchemaMismatch(format!(
1258 "PostgreSQL primary key column '{primary_key}' contains NULL"
1259 )));
1260 }
1261 }
1262 if config.changelog_mode {
1263 validate_changelog_input(batch)?;
1264 }
1265 Ok(())
1266}
1267
1268fn validate_operation_values(operations: &arrow_array::StringArray) -> Result<(), ConnectorError> {
1269 for row in 0..operations.len() {
1270 if operations.is_null(row) {
1271 return Err(ConnectorError::SchemaMismatch(format!(
1272 "PostgreSQL changelog '_op' is NULL at row {row}"
1273 )));
1274 }
1275 let operation = operations.value(row);
1276 if !matches!(operation, "I" | "U" | "U+" | "R" | "r" | "D" | "U-") {
1277 return Err(ConnectorError::SchemaMismatch(format!(
1278 "PostgreSQL changelog operation '{operation}' at row {row} is not supported"
1279 )));
1280 }
1281 }
1282 Ok(())
1283}
1284
1285fn validate_changelog_input(batch: &RecordBatch) -> Result<(), ConnectorError> {
1286 let schema = batch.schema();
1287 let mut encoding_found = false;
1288 if let Ok(index) = schema.index_of("_op") {
1289 encoding_found = true;
1290 let operations = batch
1291 .column(index)
1292 .as_any()
1293 .downcast_ref::<arrow_array::StringArray>()
1294 .ok_or_else(|| {
1295 ConnectorError::ConfigurationError("PostgreSQL changelog '_op' must be Utf8".into())
1296 })?;
1297 validate_operation_values(operations)?;
1298 }
1299 if let Ok(index) = schema.index_of("__weight") {
1300 encoding_found = true;
1301 let weights = batch
1302 .column(index)
1303 .as_any()
1304 .downcast_ref::<arrow_array::Int64Array>()
1305 .ok_or_else(|| {
1306 ConnectorError::ConfigurationError(
1307 "PostgreSQL changelog '__weight' must be Int64".into(),
1308 )
1309 })?;
1310 if weights.null_count() != 0 {
1311 return Err(ConnectorError::SchemaMismatch(
1312 "PostgreSQL changelog '__weight' contains NULL".into(),
1313 ));
1314 }
1315 }
1316 if !encoding_found {
1317 return Err(ConnectorError::ConfigurationError(
1318 "PostgreSQL changelog input requires '_op' or '__weight'".into(),
1319 ));
1320 }
1321 Ok(())
1322}
1323
1324fn filter_batch_by_indices(
1328 batch: &RecordBatch,
1329 indices: &[u32],
1330) -> Result<RecordBatch, ConnectorError> {
1331 if indices.is_empty() {
1332 let user_schema = Arc::new(Schema::new(
1333 batch
1334 .schema()
1335 .fields()
1336 .iter()
1337 .filter(|field| !is_changelog_metadata(field.name()))
1338 .cloned()
1339 .collect::<Vec<_>>(),
1340 ));
1341 return Ok(RecordBatch::new_empty(user_schema));
1342 }
1343
1344 let indices_array = arrow_array::UInt32Array::from(indices.to_vec());
1345
1346 let user_schema = Arc::new(Schema::new(
1347 batch
1348 .schema()
1349 .fields()
1350 .iter()
1351 .filter(|field| !is_changelog_metadata(field.name()))
1352 .cloned()
1353 .collect::<Vec<_>>(),
1354 ));
1355
1356 let filtered_columns: Vec<Arc<dyn arrow_array::Array>> = batch
1357 .schema()
1358 .fields()
1359 .iter()
1360 .enumerate()
1361 .filter(|(_, field)| !is_changelog_metadata(field.name()))
1362 .map(|(i, _)| {
1363 arrow_select::take::take(batch.column(i), &indices_array, None)
1364 .map_err(|e| ConnectorError::Internal(format!("arrow take failed: {e}")))
1365 })
1366 .collect::<Result<Vec<_>, _>>()?;
1367
1368 RecordBatch::try_new(user_schema, filtered_columns)
1369 .map_err(|e| ConnectorError::Internal(format!("batch construction failed: {e}")))
1370}
1371
1372fn strip_metadata_columns(batch: &RecordBatch) -> Result<RecordBatch, ConnectorError> {
1374 let schema = batch.schema();
1375 let user_indices: Vec<usize> = schema
1376 .fields()
1377 .iter()
1378 .enumerate()
1379 .filter(|(_, field)| !is_changelog_metadata(field.name()))
1380 .map(|(i, _)| i)
1381 .collect();
1382
1383 if user_indices.len() == schema.fields().len() {
1384 return Ok(batch.clone());
1385 }
1386
1387 let user_schema = Arc::new(Schema::new(
1388 user_indices
1389 .iter()
1390 .map(|&i| schema.field(i).clone())
1391 .collect::<Vec<_>>(),
1392 ));
1393 let columns: Vec<Arc<dyn Array>> = user_indices
1394 .iter()
1395 .map(|&i| batch.column(i).clone())
1396 .collect();
1397
1398 RecordBatch::try_new(user_schema, columns)
1399 .map_err(|e| ConnectorError::Internal(format!("strip metadata: {e}")))
1400}
1401
1402fn collapse_upsert_batch(
1406 batch: &RecordBatch,
1407 primary_key_columns: &[String],
1408) -> Result<RecordBatch, ConnectorError> {
1409 let collapsed = collapse_changelog(batch, primary_key_columns)?;
1410 strip_metadata_columns(&collapsed)
1411}
1412
1413#[cfg(feature = "postgres-sink")]
1415async fn execute_unnest<C>(
1416 client: &C,
1417 sql: &str,
1418 batch: &RecordBatch,
1419) -> Result<u64, ConnectorError>
1420where
1421 C: tokio_postgres::GenericClient + Sync,
1422{
1423 let params: Vec<Box<dyn postgres_types::ToSql + Sync + Send>> = (0..batch.num_columns())
1424 .map(|i| arrow_column_to_pg_array(batch.column(i)))
1425 .collect::<Result<_, _>>()?;
1426
1427 let param_refs: Vec<&(dyn postgres_types::ToSql + Sync)> = params
1428 .iter()
1429 .map(|p| p.as_ref() as &(dyn postgres_types::ToSql + Sync))
1430 .collect();
1431
1432 client
1433 .execute(sql, ¶m_refs)
1434 .await
1435 .map_err(|error| postgres_dispatched_write_error("UNNEST execute", &error))
1436}
1437
1438#[cfg(any(feature = "postgres-sink", test))]
1439fn retained_batch_bytes(batch: &RecordBatch) -> usize {
1440 let batch_overhead = std::mem::size_of::<RecordBatch>().saturating_add(
1441 batch
1442 .num_columns()
1443 .saturating_mul(std::mem::size_of::<Arc<dyn Array>>()),
1444 );
1445 batch
1446 .columns()
1447 .iter()
1448 .fold(batch_overhead, |total, column| {
1449 total.saturating_add(column.get_array_memory_size())
1450 })
1451}
1452
1453#[cfg(feature = "postgres-sink")]
1454fn retained_batch_bytes_u64(batch: &RecordBatch) -> u64 {
1455 u64::try_from(retained_batch_bytes(batch)).unwrap_or(u64::MAX)
1456}
1457
1458#[cfg(any(feature = "postgres-sink", test))]
1461fn requires_preflush(
1462 buffered_bytes: usize,
1463 incoming_bytes: usize,
1464 retained_limit: usize,
1465) -> Result<bool, ConnectorError> {
1466 if incoming_bytes > retained_limit {
1467 return Err(ConnectorError::ConfigurationError(format!(
1468 "PostgreSQL sink input batch retains {incoming_bytes} bytes, exceeding the fixed \
1469 {retained_limit}-byte per-sink buffer limit; split the batch upstream"
1470 )));
1471 }
1472
1473 Ok(buffered_bytes
1474 .checked_add(incoming_bytes)
1475 .is_none_or(|total| total > retained_limit))
1476}
1477
1478#[cfg(test)]
1479mod tests {
1480 use super::*;
1481 use arrow_array::{Int64Array, StringArray};
1482 use arrow_schema::{DataType, Field, Schema};
1483
1484 #[test]
1485 fn dispatched_write_without_server_response_has_unknown_outcome() {
1486 let unknown = classify_postgres_write_failure("UNNEST execute", &"connection lost", false);
1487 assert!(unknown.is_outcome_unknown());
1488 assert!(unknown.to_string().contains("may have committed"));
1489
1490 let rejected = classify_postgres_write_failure("UNNEST execute", &"unique violation", true);
1491 assert!(matches!(rejected, ConnectorError::WriteError(_)));
1492 assert!(!rejected.is_outcome_unknown());
1493 }
1494
1495 #[test]
1496 fn uncommitted_transaction_resolves_an_ambiguous_statement_outcome() {
1497 let retryable = resolve_uncommitted_transaction_error(ConnectorError::outcome_unknown(
1498 "connection lost after UNNEST",
1499 true,
1500 ));
1501 assert!(matches!(retryable, ConnectorError::ConnectionFailed(_)));
1502 assert!(retryable.is_transient());
1503 assert!(!retryable.is_outcome_unknown());
1504
1505 let terminal = resolve_uncommitted_transaction_error(ConnectorError::outcome_unknown(
1506 "protocol state invalid",
1507 false,
1508 ));
1509 assert!(matches!(terminal, ConnectorError::TransactionError(_)));
1510 assert!(!terminal.is_transient());
1511 assert!(!terminal.is_outcome_unknown());
1512 }
1513
1514 fn test_schema() -> SchemaRef {
1515 Arc::new(Schema::new(vec![
1516 Field::new("id", DataType::Int64, false),
1517 Field::new("name", DataType::Utf8, true),
1518 Field::new("value", DataType::Float64, true),
1519 ]))
1520 }
1521
1522 fn composite_key_schema() -> SchemaRef {
1523 Arc::new(Schema::new(vec![
1524 Field::new("id", DataType::Int64, false),
1525 Field::new("name", DataType::Utf8, false),
1526 Field::new("value", DataType::Float64, true),
1527 ]))
1528 }
1529
1530 fn test_config() -> PostgresSinkConfig {
1531 PostgresSinkConfig::new("localhost", "mydb", "events")
1532 }
1533
1534 fn upsert_config() -> PostgresSinkConfig {
1535 let mut cfg = test_config();
1536 cfg.write_mode = WriteMode::Upsert;
1537 cfg.primary_key_columns = vec!["id".to_string()];
1538 cfg
1539 }
1540
1541 fn test_batch(n: usize) -> RecordBatch {
1542 let ids: Vec<i64> = (0..n as i64).collect();
1543 let names: Vec<&str> = (0..n).map(|_| "test").collect();
1544 let values: Vec<f64> = (0..n).map(|i| i as f64 * 1.5).collect();
1545
1546 RecordBatch::try_new(
1547 test_schema(),
1548 vec![
1549 Arc::new(Int64Array::from(ids)),
1550 Arc::new(StringArray::from(names)),
1551 Arc::new(arrow_array::Float64Array::from(values)),
1552 ],
1553 )
1554 .expect("test batch creation")
1555 }
1556
1557 fn variable_width_batch(value: &str) -> RecordBatch {
1558 RecordBatch::try_new(
1559 test_schema(),
1560 vec![
1561 Arc::new(Int64Array::from(vec![1])),
1562 Arc::new(StringArray::from(vec![value])),
1563 Arc::new(arrow_array::Float64Array::from(vec![1.0])),
1564 ],
1565 )
1566 .expect("variable-width test batch")
1567 }
1568
1569 #[test]
1572 fn test_new_defaults() {
1573 let sink = PostgresSink::new(test_schema(), test_config(), None);
1574 assert_eq!(sink.state(), ConnectorState::Created);
1575 assert_eq!(sink.buffered_rows(), 0);
1576 assert_eq!(sink.buffered_retained_bytes, 0);
1577 assert!(sink.upsert_sql.is_none());
1578 assert!(sink.copy_sql.is_none());
1579 }
1580
1581 #[test]
1582 fn constructor_uses_small_fixed_buffer_preallocation() {
1583 let sink = PostgresSink::new(test_schema(), test_config(), None);
1584 assert_eq!(sink.buffer.capacity(), 4);
1585 }
1586
1587 #[test]
1588 fn test_user_schema_strips_metadata() {
1589 let schema = Arc::new(Schema::new(vec![
1590 Field::new("id", DataType::Int64, false),
1591 Field::new("_op", DataType::Utf8, false),
1592 Field::new("_private_value", DataType::Utf8, true),
1593 Field::new("value", DataType::Utf8, true),
1594 ]));
1595 let sink = PostgresSink::new(schema, test_config(), None);
1596 assert_eq!(sink.user_schema.fields().len(), 3);
1597 assert_eq!(sink.user_schema.field(0).name(), "id");
1598 assert_eq!(sink.user_schema.field(1).name(), "_private_value");
1599 assert_eq!(sink.user_schema.field(2).name(), "value");
1600 }
1601
1602 #[test]
1603 fn test_schema_returned() {
1604 let schema = test_schema();
1605 let sink = PostgresSink::new(schema.clone(), test_config(), None);
1606 assert_eq!(sink.schema(), schema);
1607 }
1608
1609 #[test]
1610 fn engine_schema_replaces_placeholder_and_drives_write_sql() {
1611 let engine_schema = Arc::new(Schema::new(vec![
1612 Field::new("tenant", DataType::Utf8, false),
1613 Field::new("sequence", DataType::Int64, false),
1614 Field::new("enabled", DataType::Boolean, true),
1615 Field::new("_op", DataType::Utf8, false),
1616 ]));
1617 let placeholder = Arc::new(Schema::new(vec![Field::new(
1618 "value",
1619 DataType::Utf8,
1620 false,
1621 )]));
1622 let mut config = ConnectorConfig::new("postgres-sink");
1623 config.set("hostname", "localhost");
1624 config.set("database", "analytics");
1625 config.set("username", "writer");
1626 config.set("table.name", "events");
1627 config.set("auto.create.table", "true");
1628 config.set("write.mode", "upsert");
1629 config.set("primary.key", "tenant");
1630 config.set("changelog.mode", "true");
1631 config.set(
1632 "_arrow_schema",
1633 crate::config::encode_arrow_schema_ipc(engine_schema.as_ref()),
1634 );
1635
1636 let mut sink = PostgresSink::new(placeholder, test_config(), None);
1637 sink.apply_connector_config(&config).unwrap();
1638 sink.prepare_statements().unwrap();
1639
1640 assert_eq!(sink.schema, engine_schema);
1641 assert_eq!(
1642 sink.user_schema
1643 .fields()
1644 .iter()
1645 .map(|f| f.name().as_str())
1646 .collect::<Vec<_>>(),
1647 vec!["tenant", "sequence", "enabled"]
1648 );
1649 let upsert = sink.upsert_sql.as_deref().unwrap();
1650 assert!(upsert.contains("\"tenant\"") && upsert.contains("\"sequence\""));
1651 let ddl = sink.create_table_sql.as_deref().unwrap();
1652 assert!(ddl.contains("\"tenant\" TEXT NOT NULL"), "{ddl}");
1653 assert!(ddl.contains("\"sequence\" BIGINT NOT NULL"), "{ddl}");
1654 assert!(ddl.contains("\"enabled\" BOOLEAN"), "{ddl}");
1655 assert!(!ddl.contains("_op"), "{ddl}");
1656 }
1657
1658 #[test]
1661 fn test_build_copy_sql() {
1662 let schema = test_schema();
1663 let config = test_config();
1664 let sql = PostgresSink::build_copy_sql(&schema, &config).unwrap();
1665 assert_eq!(
1666 sql,
1667 "COPY \"public\".\"events\" (\"id\", \"name\", \"value\") FROM STDIN BINARY"
1668 );
1669 }
1670
1671 #[test]
1672 fn test_build_copy_sql_custom_schema() {
1673 let schema = test_schema();
1674 let mut config = test_config();
1675 config.schema_name = "analytics".to_string();
1676 let sql = PostgresSink::build_copy_sql(&schema, &config).unwrap();
1677 assert!(sql.starts_with("COPY \"analytics\".\"events\""));
1678 }
1679
1680 #[test]
1681 fn sql_generation_quotes_reserved_mixed_case_and_embedded_quote_identifiers() {
1682 let schema = Arc::new(Schema::new(vec![
1683 Field::new("select", DataType::Int64, false),
1684 Field::new("MixedCase", DataType::Utf8, true),
1685 Field::new("a\"b", DataType::Boolean, true),
1686 ]));
1687 let mut config = test_config();
1688 config.schema_name = "Tenant.Schema".into();
1689 config.table_name = "Order".into();
1690
1691 let sql = PostgresSink::build_copy_sql(&schema, &config).unwrap();
1692 assert_eq!(
1693 sql,
1694 "COPY \"Tenant.Schema\".\"Order\" (\"select\", \"MixedCase\", \"a\"\"b\") FROM STDIN BINARY"
1695 );
1696 }
1697
1698 #[test]
1699 fn schema_admission_rejects_unsupported_types_and_nullable_keys() {
1700 let unsupported = Arc::new(Schema::new(vec![Field::new(
1701 "nested",
1702 DataType::List(Arc::new(Field::new("item", DataType::Int64, true))),
1703 true,
1704 )]));
1705 assert!(PostgresSink::build_copy_sql(&unsupported, &test_config()).is_err());
1706
1707 let nullable_key = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, true)]));
1708 assert!(PostgresSink::build_upsert_sql(&nullable_key, &upsert_config()).is_err());
1709
1710 let nul_name = Arc::new(Schema::new(vec![Field::new(
1711 "bad\0column",
1712 DataType::Int64,
1713 false,
1714 )]));
1715 assert!(PostgresSink::build_copy_sql(&nul_name, &test_config()).is_err());
1716 }
1717
1718 #[test]
1719 fn test_build_copy_sql_excludes_metadata_columns() {
1720 let schema = Arc::new(Schema::new(vec![
1721 Field::new("id", DataType::Int64, false),
1722 Field::new("_op", DataType::Utf8, false),
1723 Field::new("_ts_ms", DataType::Int64, false),
1724 Field::new("value", DataType::Utf8, true),
1725 ]));
1726 let mut config = upsert_config();
1727 config.changelog_mode = true;
1728 let sql = PostgresSink::build_copy_sql(&schema, &config).unwrap();
1729 assert_eq!(
1730 sql,
1731 "COPY \"public\".\"events\" (\"id\", \"value\") FROM STDIN BINARY"
1732 );
1733 }
1734
1735 #[test]
1736 fn timestamp_metadata_is_never_silently_dropped_outside_changelog_mode() {
1737 let schema = Arc::new(Schema::new(vec![
1738 Field::new("id", DataType::Int64, false),
1739 Field::new("_ts_ms", DataType::Int64, false),
1740 ]));
1741 let error = PostgresSink::build_copy_sql(&schema, &test_config()).unwrap_err();
1742 assert!(error.to_string().contains("changelog.mode=true"));
1743
1744 let user_underscore = Arc::new(Schema::new(vec![
1745 Field::new("id", DataType::Int64, false),
1746 Field::new("_private", DataType::Utf8, true),
1747 ]));
1748 let sql = PostgresSink::build_copy_sql(&user_underscore, &test_config()).unwrap();
1749 assert!(sql.contains("\"_private\""), "{sql}");
1750 }
1751
1752 #[test]
1753 fn test_build_upsert_sql() {
1754 let schema = test_schema();
1755 let config = upsert_config();
1756 let sql = PostgresSink::build_upsert_sql(&schema, &config).unwrap();
1757
1758 assert!(sql.starts_with("INSERT INTO \"public\".\"events\""));
1759 assert!(sql.contains("SELECT * FROM UNNEST"));
1760 assert!(sql.contains("$1::int8[]"));
1761 assert!(sql.contains("$2::text[]"));
1762 assert!(sql.contains("$3::float8[]"));
1763 assert!(sql.contains("ON CONFLICT (\"id\")"));
1764 assert!(sql.contains("DO UPDATE SET"));
1765 assert!(sql.contains("\"name\" = EXCLUDED.\"name\""));
1766 assert!(sql.contains("\"value\" = EXCLUDED.\"value\""));
1767 assert!(!sql.contains("\"id\" = EXCLUDED.\"id\""));
1768 }
1769
1770 #[test]
1771 fn test_build_upsert_sql_composite_key() {
1772 let schema = composite_key_schema();
1773 let mut config = upsert_config();
1774 config.primary_key_columns = vec!["id".to_string(), "name".to_string()];
1775 let sql = PostgresSink::build_upsert_sql(&schema, &config).unwrap();
1776
1777 assert!(sql.contains("ON CONFLICT (\"id\", \"name\")"));
1778 assert!(sql.contains("\"value\" = EXCLUDED.\"value\""));
1779 assert!(!sql.contains("\"id\" = EXCLUDED.\"id\""));
1780 assert!(!sql.contains("\"name\" = EXCLUDED.\"name\""));
1781 }
1782
1783 #[test]
1784 fn test_build_upsert_sql_key_only_table() {
1785 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
1786 let mut config = test_config();
1787 config.write_mode = WriteMode::Upsert;
1788 config.primary_key_columns = vec!["id".to_string()];
1789
1790 let sql = PostgresSink::build_upsert_sql(&schema, &config).unwrap();
1791 assert!(sql.contains("DO NOTHING"), "sql: {sql}");
1792 }
1793
1794 #[test]
1795 fn test_build_delete_sql() {
1796 let schema = test_schema();
1797 let config = upsert_config();
1798 let sql = PostgresSink::build_delete_sql(&schema, &config).unwrap();
1799
1800 assert_eq!(
1801 sql,
1802 "DELETE FROM \"public\".\"events\" WHERE \"id\" = ANY($1::int8[])"
1803 );
1804 }
1805
1806 #[test]
1807 fn test_build_delete_sql_composite_key() {
1808 let schema = composite_key_schema();
1809 let mut config = upsert_config();
1810 config.primary_key_columns = vec!["id".to_string(), "name".to_string()];
1811 let sql = PostgresSink::build_delete_sql(&schema, &config).unwrap();
1812
1813 assert_eq!(
1816 sql,
1817 "DELETE FROM \"public\".\"events\" AS \"target\" USING UNNEST($1::int8[], \
1818 $2::text[]) AS \"keys\"(\"id\", \"name\") WHERE \"target\".\"id\" = \
1819 \"keys\".\"id\" AND \"target\".\"name\" = \"keys\".\"name\""
1820 );
1821 assert!(!sql.contains("ANY($1::int8[]) AND"));
1822 }
1823
1824 #[test]
1825 fn test_build_create_table_sql() {
1826 let schema = test_schema();
1827 let config = upsert_config();
1828 let sql = PostgresSink::build_create_table_sql(&schema, &config).unwrap();
1829
1830 assert!(sql.starts_with("CREATE TABLE IF NOT EXISTS \"public\".\"events\""));
1831 assert!(sql.contains("\"id\" BIGINT NOT NULL"));
1832 assert!(sql.contains("\"name\" TEXT"));
1833 assert!(sql.contains("\"value\" DOUBLE PRECISION"));
1834 assert!(sql.contains("PRIMARY KEY (\"id\")"));
1835 }
1836
1837 #[test]
1838 fn test_build_create_table_sql_no_pk() {
1839 let schema = test_schema();
1840 let config = test_config();
1841 let sql = PostgresSink::build_create_table_sql(&schema, &config).unwrap();
1842
1843 assert!(sql.starts_with("CREATE TABLE IF NOT EXISTS"));
1844 assert!(!sql.contains("PRIMARY KEY"));
1845 }
1846
1847 #[test]
1848 fn ordinary_upsert_collapse_keeps_last_row_per_primary_key() {
1849 let batch = RecordBatch::try_new(
1850 Arc::new(Schema::new(vec![
1851 Field::new("id", DataType::Int64, false),
1852 Field::new("value", DataType::Utf8, false),
1853 ])),
1854 vec![
1855 Arc::new(Int64Array::from(vec![1, 2, 1])),
1856 Arc::new(StringArray::from(vec!["old", "other", "new"])),
1857 ],
1858 )
1859 .unwrap();
1860
1861 let collapsed = collapse_upsert_batch(&batch, &["id".to_string()]).unwrap();
1862
1863 assert_eq!(collapsed.num_rows(), 2);
1864 assert!(collapsed.schema().index_of("_op").is_err());
1865 let ids = collapsed
1866 .column(0)
1867 .as_any()
1868 .downcast_ref::<Int64Array>()
1869 .unwrap();
1870 let values = collapsed
1871 .column(1)
1872 .as_any()
1873 .downcast_ref::<StringArray>()
1874 .unwrap();
1875 let id_one = (0..collapsed.num_rows())
1876 .find(|&row| ids.value(row) == 1)
1877 .unwrap();
1878 assert_eq!(values.value(id_one), "new");
1879 }
1880
1881 fn changelog_schema() -> SchemaRef {
1884 Arc::new(Schema::new(vec![
1885 Field::new("id", DataType::Int64, false),
1886 Field::new("name", DataType::Utf8, true),
1887 Field::new("_op", DataType::Utf8, false),
1888 Field::new("_ts_ms", DataType::Int64, false),
1889 ]))
1890 }
1891
1892 fn changelog_batch() -> RecordBatch {
1893 RecordBatch::try_new(
1894 changelog_schema(),
1895 vec![
1896 Arc::new(Int64Array::from(vec![1, 2, 3, 4, 5])),
1897 Arc::new(StringArray::from(vec!["a", "b", "c", "d", "e"])),
1898 Arc::new(StringArray::from(vec!["I", "U", "D", "I", "D"])),
1899 Arc::new(Int64Array::from(vec![100, 200, 300, 400, 500])),
1900 ],
1901 )
1902 .expect("changelog batch creation")
1903 }
1904
1905 #[test]
1906 fn test_split_changelog_batch() {
1907 let batch = changelog_batch();
1908 let (inserts, deletes) = PostgresSink::split_changelog_batch(&batch).expect("split");
1909
1910 assert_eq!(inserts.num_rows(), 3);
1911 assert_eq!(deletes.num_rows(), 2);
1912 assert_eq!(inserts.num_columns(), 2);
1913 assert_eq!(deletes.num_columns(), 2);
1914
1915 let insert_ids = inserts
1916 .column(0)
1917 .as_any()
1918 .downcast_ref::<Int64Array>()
1919 .expect("i64 array");
1920 assert_eq!(insert_ids.value(0), 1);
1921 assert_eq!(insert_ids.value(1), 2);
1922 assert_eq!(insert_ids.value(2), 4);
1923
1924 let delete_ids = deletes
1925 .column(0)
1926 .as_any()
1927 .downcast_ref::<Int64Array>()
1928 .expect("i64 array");
1929 assert_eq!(delete_ids.value(0), 3);
1930 assert_eq!(delete_ids.value(1), 5);
1931 }
1932
1933 #[test]
1934 fn test_split_changelog_all_inserts() {
1935 let schema = changelog_schema();
1936 let batch = RecordBatch::try_new(
1937 schema,
1938 vec![
1939 Arc::new(Int64Array::from(vec![1, 2])),
1940 Arc::new(StringArray::from(vec!["a", "b"])),
1941 Arc::new(StringArray::from(vec!["I", "I"])),
1942 Arc::new(Int64Array::from(vec![100, 200])),
1943 ],
1944 )
1945 .expect("batch");
1946
1947 let (inserts, deletes) = PostgresSink::split_changelog_batch(&batch).expect("split");
1948 assert_eq!(inserts.num_rows(), 2);
1949 assert_eq!(deletes.num_rows(), 0);
1950 }
1951
1952 #[test]
1953 fn test_split_changelog_all_deletes() {
1954 let schema = changelog_schema();
1955 let batch = RecordBatch::try_new(
1956 schema,
1957 vec![
1958 Arc::new(Int64Array::from(vec![1, 2])),
1959 Arc::new(StringArray::from(vec!["a", "b"])),
1960 Arc::new(StringArray::from(vec!["D", "D"])),
1961 Arc::new(Int64Array::from(vec![100, 200])),
1962 ],
1963 )
1964 .expect("batch");
1965
1966 let (inserts, deletes) = PostgresSink::split_changelog_batch(&batch).expect("split");
1967 assert_eq!(inserts.num_rows(), 0);
1968 assert_eq!(deletes.num_rows(), 2);
1969 }
1970
1971 #[test]
1972 fn test_split_changelog_missing_op_column() {
1973 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
1974 let batch =
1975 RecordBatch::try_new(schema, vec![Arc::new(Int64Array::from(vec![1]))]).expect("batch");
1976
1977 let result = PostgresSink::split_changelog_batch(&batch);
1978 assert!(result.is_err());
1979 }
1980
1981 #[test]
1982 fn test_split_changelog_snapshot_read() {
1983 let schema = changelog_schema();
1984 let batch = RecordBatch::try_new(
1985 schema,
1986 vec![
1987 Arc::new(Int64Array::from(vec![1])),
1988 Arc::new(StringArray::from(vec!["a"])),
1989 Arc::new(StringArray::from(vec!["r"])),
1990 Arc::new(Int64Array::from(vec![100])),
1991 ],
1992 )
1993 .expect("batch");
1994
1995 let (inserts, deletes) = PostgresSink::split_changelog_batch(&batch).expect("split");
1996 assert_eq!(inserts.num_rows(), 1);
1997 assert_eq!(deletes.num_rows(), 0);
1998 }
1999
2000 #[test]
2001 fn changelog_null_and_unknown_operations_fail_closed() {
2002 for operations in [
2003 StringArray::from(vec![Some("I"), None]),
2004 StringArray::from(vec![Some("I"), Some("future")]),
2005 ] {
2006 let schema = Arc::new(Schema::new(vec![
2007 Field::new("id", DataType::Int64, false),
2008 Field::new("_op", DataType::Utf8, true),
2009 ]));
2010 let batch = RecordBatch::try_new(
2011 schema,
2012 vec![Arc::new(Int64Array::from(vec![1, 2])), Arc::new(operations)],
2013 )
2014 .unwrap();
2015 assert!(PostgresSink::split_changelog_batch(&batch).is_err());
2016 }
2017 }
2018
2019 #[cfg(feature = "postgres-sink")]
2020 #[tokio::test]
2021 async fn invalid_changelog_operation_is_rejected_before_buffer_mutation() {
2022 let schema = Arc::new(Schema::new(vec![
2023 Field::new("id", DataType::Int64, false),
2024 Field::new("_op", DataType::Utf8, false),
2025 ]));
2026 let batch = RecordBatch::try_new(
2027 schema.clone(),
2028 vec![
2029 Arc::new(Int64Array::from(vec![1])),
2030 Arc::new(StringArray::from(vec!["future"])),
2031 ],
2032 )
2033 .unwrap();
2034 let mut config = upsert_config();
2035 config.changelog_mode = true;
2036 let mut sink = PostgresSink::new(schema, config, None);
2037 sink.state = ConnectorState::Running;
2038
2039 let error = sink.write_batch(&batch).await.unwrap_err();
2040 assert!(!error.is_transient());
2041 assert!(sink.buffer.is_empty());
2042 assert_eq!(sink.buffered_rows, 0);
2043 assert_eq!(sink.buffered_retained_bytes, 0);
2044 }
2045
2046 #[tokio::test]
2049 async fn test_write_batch_buffering() {
2050 let mut sink = PostgresSink::new(test_schema(), test_config(), None);
2051 sink.state = ConnectorState::Running;
2052
2053 let batch = test_batch(10);
2054 let result = sink.write_batch(&batch).await.expect("write");
2055
2056 assert_eq!(result.records_written, 0);
2057 assert_eq!(sink.buffered_rows(), 10);
2058 assert_eq!(sink.buffered_retained_bytes, retained_batch_bytes(&batch));
2059 }
2060
2061 #[tokio::test]
2062 async fn test_write_batch_empty() {
2063 let mut sink = PostgresSink::new(test_schema(), test_config(), None);
2064 sink.state = ConnectorState::Running;
2065
2066 let batch = test_batch(0);
2067 let result = sink.write_batch(&batch).await.expect("write");
2068 assert_eq!(result.records_written, 0);
2069 assert_eq!(sink.buffered_rows(), 0);
2070 assert_eq!(sink.buffered_retained_bytes, 0);
2071 }
2072
2073 #[tokio::test]
2074 async fn test_write_batch_not_running() {
2075 let mut sink = PostgresSink::new(test_schema(), test_config(), None);
2076
2077 let batch = test_batch(10);
2078 let result = sink.write_batch(&batch).await;
2079 assert!(result.is_err());
2080 }
2081
2082 #[test]
2083 fn retained_limit_uses_variable_width_memory_and_allows_exact_boundary() {
2084 let narrow = variable_width_batch("x");
2085 let wide_value = "x".repeat(4096);
2086 let wide = variable_width_batch(&wide_value);
2087 let narrow_bytes = retained_batch_bytes(&narrow);
2088 let wide_bytes = retained_batch_bytes(&wide);
2089 assert!(wide_bytes > narrow_bytes);
2090
2091 let exact_limit = narrow_bytes + wide_bytes;
2092 assert!(!requires_preflush(narrow_bytes, wide_bytes, exact_limit).unwrap());
2093 assert!(requires_preflush(narrow_bytes, wide_bytes, exact_limit - 1).unwrap());
2094 assert!(requires_preflush(usize::MAX, 1, usize::MAX).unwrap());
2095 }
2096
2097 #[cfg(feature = "postgres-sink")]
2098 #[tokio::test]
2099 async fn oversized_batch_rejection_does_not_mutate_existing_buffer() {
2100 let mut sink = PostgresSink::new(test_schema(), test_config(), None);
2101 sink.state = ConnectorState::Running;
2102
2103 let existing = variable_width_batch("retained");
2104 sink.write_batch_with_retained_limit(&existing, usize::MAX)
2105 .await
2106 .unwrap();
2107 let rows_before = sink.buffered_rows;
2108 let bytes_before = sink.buffered_retained_bytes;
2109 let batches_before = sink.buffer.len();
2110
2111 let incoming_value = "x".repeat(4096);
2112 let incoming = variable_width_batch(&incoming_value);
2113 let incoming_bytes = retained_batch_bytes(&incoming);
2114 let error = sink
2115 .write_batch_with_retained_limit(&incoming, incoming_bytes - 1)
2116 .await
2117 .expect_err("single oversized batch must fail before admission");
2118
2119 assert!(error.to_string().contains("split the batch upstream"));
2120 assert!(!error.is_transient());
2121 assert_eq!(sink.buffered_rows, rows_before);
2122 assert_eq!(sink.buffered_retained_bytes, bytes_before);
2123 assert_eq!(sink.buffer.len(), batches_before);
2124 assert_eq!(sink.buffer[0].num_rows(), existing.num_rows());
2125 }
2126
2127 #[cfg(feature = "postgres-sink")]
2128 #[tokio::test]
2129 async fn explicit_flush_error_clears_buffer_accounting() {
2130 let mut sink = PostgresSink::new(test_schema(), test_config(), None);
2131 sink.state = ConnectorState::Running;
2132
2133 sink.write_batch_with_retained_limit(&variable_width_batch("pending"), usize::MAX)
2134 .await
2135 .unwrap();
2136 let error = sink
2137 .flush()
2138 .await
2139 .expect_err("missing pool must fail flush");
2140
2141 assert!(matches!(error, ConnectorError::InvalidState { .. }));
2142 assert!(sink.buffer.is_empty());
2143 assert_eq!(sink.buffered_rows, 0);
2144 assert_eq!(sink.buffered_retained_bytes, 0);
2145 }
2146
2147 #[cfg(feature = "postgres-sink")]
2148 #[tokio::test]
2149 async fn crossing_batch_flushes_existing_before_admission() {
2150 let mut sink = PostgresSink::new(test_schema(), test_config(), None);
2151 sink.state = ConnectorState::Running;
2152
2153 let existing = variable_width_batch("existing");
2154 sink.write_batch_with_retained_limit(&existing, usize::MAX)
2155 .await
2156 .unwrap();
2157 let incoming = variable_width_batch("incoming");
2158 let crossing_limit =
2159 retained_batch_bytes(&existing).saturating_add(retained_batch_bytes(&incoming)) - 1;
2160
2161 let error = sink
2162 .write_batch_with_retained_limit(&incoming, crossing_limit)
2163 .await
2164 .expect_err("crossing admission must flush the existing buffer first");
2165
2166 assert!(matches!(error, ConnectorError::InvalidState { .. }));
2167 assert!(sink.buffer.is_empty());
2168 assert_eq!(sink.buffered_rows, 0);
2169 assert_eq!(sink.buffered_retained_bytes, 0);
2170 }
2171
2172 #[cfg(feature = "postgres-sink")]
2173 #[tokio::test]
2174 async fn close_reports_flush_failure_but_releases_state() {
2175 let mut sink = PostgresSink::new(test_schema(), test_config(), None);
2176 sink.state = ConnectorState::Running;
2177 sink.write_batch_with_retained_limit(&variable_width_batch("pending"), usize::MAX)
2178 .await
2179 .unwrap();
2180
2181 let error = sink
2182 .close()
2183 .await
2184 .expect_err("missing pool must fail flush");
2185
2186 assert!(matches!(error, ConnectorError::InvalidState { .. }));
2187 assert!(sink.buffer.is_empty());
2188 assert_eq!(sink.buffered_rows, 0);
2189 assert_eq!(sink.buffered_retained_bytes, 0);
2190 assert_eq!(sink.state, ConnectorState::Closed);
2191 }
2192
2193 #[cfg(feature = "postgres-sink")]
2196 #[test]
2197 fn contract_append_is_multi_writer_durable_at_least_once() {
2198 let sink = PostgresSink::new(test_schema(), test_config(), None);
2199 let contract = sink.contract(&ConnectorConfig::new("postgres")).unwrap();
2200 assert_eq!(contract.consistency, SinkConsistency::DurableAtLeastOnce);
2201 assert_eq!(contract.topology, SinkTopology::MultiWriter);
2202 assert_eq!(contract.input_mode, SinkInputMode::AppendOnly);
2203 assert_eq!(
2204 sink.suggested_write_timeout(),
2205 sink.config.statement_timeout + Duration::from_secs(5)
2206 );
2207 assert_eq!(sink.flush_interval(), Duration::from_millis(250));
2208 }
2209
2210 #[cfg(feature = "postgres-sink")]
2211 #[test]
2212 fn contract_upsert_requires_keyed_input() {
2213 let sink = PostgresSink::new(test_schema(), upsert_config(), None);
2214 let contract = sink.contract(&ConnectorConfig::new("postgres")).unwrap();
2215 assert_eq!(contract.input_mode, SinkInputMode::KeyedUpsert);
2216 assert_eq!(contract.topology, SinkTopology::Singleton);
2217 }
2218
2219 #[cfg(feature = "postgres-sink")]
2220 #[test]
2221 fn contract_changelog_accepts_full_changelog() {
2222 let mut config = upsert_config();
2223 config.changelog_mode = true;
2224 let sink = PostgresSink::new(changelog_schema(), config, None);
2225 let contract = sink.contract(&ConnectorConfig::new("postgres")).unwrap();
2226 assert_eq!(contract.input_mode, SinkInputMode::FullChangelog);
2227 assert_eq!(contract.topology, SinkTopology::Singleton);
2228 assert!(contract.accepts_full_changelog());
2229 assert_eq!(
2230 sink.suggested_write_timeout(),
2231 sink.config.statement_timeout.saturating_mul(2) + Duration::from_secs(5)
2232 );
2233 }
2234
2235 #[cfg(feature = "postgres-sink")]
2236 #[tokio::test]
2237 async fn open_rejects_invalid_ca_before_network_io() {
2238 let directory = tempfile::tempdir().unwrap();
2239 let ca_path = directory.path().join("missing.pem");
2240 let mut config = test_config();
2241 config.ssl_ca_cert_path = Some(ca_path.clone());
2242 let mut sink = PostgresSink::new(test_schema(), config, None);
2243
2244 let error = sink
2245 .open(&ConnectorConfig::new("postgres-sink"))
2246 .await
2247 .expect_err("invalid custom CA must fail before pool I/O");
2248
2249 let message = error.to_string();
2250 assert!(
2251 message.contains(&ca_path.display().to_string()),
2252 "{message}"
2253 );
2254 assert_eq!(sink.state(), ConnectorState::Created);
2255 }
2256
2257 #[cfg(not(feature = "postgres-sink"))]
2258 #[test]
2259 fn missing_feature_fails_contract_before_io() {
2260 let sink = PostgresSink::new(test_schema(), test_config(), None);
2261 let error = sink
2262 .contract(&ConnectorConfig::new("postgres"))
2263 .expect_err("disabled PostgreSQL sink must fail admission");
2264 assert!(error.to_string().contains("postgres-sink"));
2265 }
2266
2267 #[test]
2270 fn test_debug_output() {
2271 let sink = PostgresSink::new(test_schema(), test_config(), None);
2272 let debug = format!("{sink:?}");
2273 assert!(debug.contains("PostgresSink"));
2274 assert!(debug.contains("public") && debug.contains("events"));
2275 }
2276
2277 #[test]
2280 fn test_build_user_schema() {
2281 let schema = Arc::new(Schema::new(vec![
2282 Field::new("id", DataType::Int64, false),
2283 Field::new("_op", DataType::Utf8, false),
2284 Field::new("value", DataType::Float64, true),
2285 Field::new("_ts_ms", DataType::Int64, false),
2286 ]));
2287 let user = build_user_schema(&schema);
2288 assert_eq!(user.fields().len(), 2);
2289 assert_eq!(user.field(0).name(), "id");
2290 assert_eq!(user.field(1).name(), "value");
2291 }
2292
2293 #[test]
2294 fn test_build_user_schema_no_metadata() {
2295 let schema = test_schema();
2296 let user = build_user_schema(&schema);
2297 assert_eq!(user.fields().len(), 3);
2298 }
2299}