Skip to main content

laminar_connectors/postgres/
sink.rs

1//! `PostgreSQL` sink connector implementation.
2//!
3//! [`PostgresSink`] implements [`SinkConnector`], writing Arrow `RecordBatch`
4//! data to `PostgreSQL` tables via two strategies:
5//!
6//! - **Append mode**: COPY BINARY for maximum throughput (>500K rows/sec)
7//! - **Upsert mode**: `INSERT ... ON CONFLICT DO UPDATE` with UNNEST arrays
8//!
9//! The connector provides durable at-least-once delivery. Typed runtime
10//! admission excludes it from exactly-once pipelines because it has no
11//! coordinated external checkpoint committer.
12//!
13//! # Ring Architecture
14//!
15//! - **Ring 0**: No sink code. Data arrives via SPSC channel (~5ns push).
16//! - **Ring 1**: Batch buffering, COPY/INSERT writes, transaction management.
17//! - **Ring 2**: Connection pool and table creation.
18
19use 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// A flush temporarily owns the buffered Arrow arrays plus a concatenated batch and either COPY
93// wire bytes or owned UNNEST parameters. Keeping the retained input at 16 MiB leaves conservative
94// headroom for those transient copies without adding another deployment tuning dimension.
95#[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
141/// `PostgreSQL` sink connector.
142///
143/// Writes Arrow `RecordBatch` to `PostgreSQL` tables using COPY BINARY
144/// (append) or UNNEST-based upsert with durable at-least-once semantics.
145pub struct PostgresSink {
146    /// Sink configuration.
147    config: PostgresSinkConfig,
148    /// Arrow schema for input batches.
149    schema: SchemaRef,
150    /// User-visible schema (metadata columns stripped).
151    user_schema: SchemaRef,
152    /// Connector lifecycle state.
153    state: ConnectorState,
154    /// Buffered records awaiting flush.
155    buffer: Vec<RecordBatch>,
156    /// Total rows in buffer.
157    buffered_rows: usize,
158    /// Arrow-reported retained bytes held by `buffer`.
159    buffered_retained_bytes: usize,
160    /// Sink metrics.
161    metrics: PostgresSinkMetrics,
162    /// Cached upsert SQL statement (for upsert mode).
163    upsert_sql: Option<String>,
164    /// Cached COPY SQL statement (for append mode).
165    copy_sql: Option<String>,
166    /// Cached CREATE TABLE SQL (for auto-create).
167    create_table_sql: Option<String>,
168    /// Cached DELETE SQL (for changelog mode).
169    delete_sql: Option<String>,
170    /// Connection pool (`None` until `open()` is called).
171    #[cfg(feature = "postgres-sink")]
172    pool: Option<Pool>,
173}
174
175impl PostgresSink {
176    /// Creates a new `PostgreSQL` sink connector.
177    #[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    /// Builds the engine-managed sink from its complete runtime configuration.
203    /// Engine-managed sinks must carry the upstream Arrow schema; using a
204    /// placeholder here would generate the wrong table and write statements.
205    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    /// Returns the current connector state.
241    #[must_use]
242    pub fn state(&self) -> ConnectorState {
243        self.state
244    }
245
246    /// Returns the number of buffered rows pending flush.
247    #[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    /// Moves pending batches out before any await so timeout cancellation cannot leave stale
260    /// records or accounting in the connector.
261    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    /// Returns a reference to the sink metrics.
268    #[must_use]
269    pub fn sink_metrics(&self) -> &PostgresSinkMetrics {
270        &self.metrics
271    }
272
273    // ── SQL Generation ──────────────────────────────────────────────
274
275    /// Builds the COPY BINARY SQL statement.
276    ///
277    /// ```sql
278    /// COPY "public"."events" ("id", "value", "ts") FROM STDIN BINARY
279    /// ```
280    ///
281    /// # Errors
282    ///
283    /// Returns an error when the schema or sink configuration cannot produce a valid COPY.
284    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    /// Builds the UNNEST-based upsert SQL statement.
298    ///
299    /// ```sql
300    /// INSERT INTO "public"."target" ("id", "value", "updated_at")
301    /// SELECT * FROM UNNEST($1::int8[], $2::text[], $3::timestamptz[])
302    /// ON CONFLICT (id) DO UPDATE SET
303    ///     value = EXCLUDED.value,
304    ///     updated_at = EXCLUDED.updated_at
305    /// ```
306    ///
307    /// # Errors
308    ///
309    /// Returns an error when the schema or sink configuration cannot produce a valid upsert.
310    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            // Key-only table: use DO NOTHING
356            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    /// Builds the DELETE SQL for changelog deletes. One array parameter is bound per primary-key
380    /// column (`$1`, `$2`, …), each holding that column's values for the batch's deleted keys.
381    ///
382    /// ```sql
383    /// -- single PK
384    /// DELETE FROM "public"."events" WHERE "id" = ANY($1::int8[])
385    /// -- composite PK: match tuple-wise via UNNEST, not the cross-product
386    /// DELETE FROM "public"."events" AS "target"
387    ///   USING UNNEST($1::int8[], $2::text[]) AS "keys"("id", "name")
388    /// ```
389    ///
390    /// # Errors
391    ///
392    /// Returns an error when the configured primary key cannot produce a valid delete.
393    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        // A single PK column can use a plain ANY(); a composite PK must match keys tuple-wise, or
409        // `col1 = ANY($1) AND col2 = ANY($2)` deletes the cross-product — e.g. deleting (1,'a') and
410        // (2,'b') would also delete (1,'b') and (2,'a') (CN-2). UNNEST zips the arrays positionally.
411        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    /// Builds CREATE TABLE DDL from the Arrow schema.
450    ///
451    /// ```sql
452    /// CREATE TABLE IF NOT EXISTS "public"."events" (
453    ///     "id" BIGINT NOT NULL,
454    ///     "value" TEXT,
455    ///     "ts" TIMESTAMPTZ,
456    ///     PRIMARY KEY ("id")
457    /// )
458    /// ```
459    ///
460    /// # Errors
461    ///
462    /// Returns an error when an Arrow field or identifier cannot be represented in `PostgreSQL`.
463    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    // ── Changelog/Retraction ────────────────────────────────────────
506
507    /// Splits a changelog `RecordBatch` into insert and delete batches.
508    ///
509    /// Uses the `_op` metadata column:
510    /// - `"I"` (insert), `"U"` (update-after), `"r"` (snapshot read) → insert batch
511    /// - `"D"` (delete) → delete batch
512    ///
513    /// The returned batches exclude the exact changelog metadata field set.
514    ///
515    /// # Errors
516    ///
517    /// Returns `ConnectorError::ConfigurationError` if the `_op` column is
518    /// missing or not a string type.
519    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    // ── Internal helpers ────────────────────────────────────────────
568
569    /// Prepares cached SQL statements based on schema and config.
570    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    /// Returns a reference to the connection pool, or an error if not initialized.
591    #[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    /// Concatenates all buffered batches and strips metadata columns.
600    #[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    /// Flushes buffered data to `PostgreSQL` using the COPY BINARY protocol.
615    #[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        // `freeze()` transfers ownership to the COPY sink, so keeping this local also guarantees
630        // cancellation releases partially encoded data.
631        let mut encode_buf = BytesMut::with_capacity(COPY_ENCODE_INITIAL_CAPACITY);
632        encoder.write_header(&mut encode_buf);
633        encoder
634            .write_batch(&copy_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    /// Flushes buffered data to `PostgreSQL` using UNNEST-based upsert.
673    #[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    /// Flushes changelog batches per primary key: one collapsed terminal op each, then upserts
706    /// before deletes.
707    #[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        // Validate the entire flush window before issuing either the UPSERT or DELETE. A malformed
719        // later batch must not be discovered after an earlier key has already mutated PostgreSQL.
720        for batch in buffer {
721            validate_changelog_input(batch)?;
722        }
723
724        // Collapse the whole buffered flush window per primary key into a cardinality-safe
725        // `{U,D}` batch that `split_changelog_batch` understands. A Z-set changelog (`__weight`,
726        // no `_op`) MUST be collapsed or its many retract+insert events per key fail the split /
727        // violate ON CONFLICT cardinality. A CDC changelog (`_op`) MUST be collapsed too:
728        // otherwise a delete-then-reinsert of a key in one flush window splits into both bins
729        // and, applied upserts-then-deletes, wrongly ends deleted (CN-3). `collapse_changelog`
730        // keeps the last arrival per key and normalizes `_op`, so each key contributes exactly
731        // one terminal U or D.
732        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        // Split each batch into inserts/deletes.
743        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    /// Executes batched DELETE for changelog delete records.
822    #[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    /// Dispatches flush to the appropriate write mode.
869    #[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    /// Flushes the current buffer, dropping the drained batches on every success, error, or
882    /// cancellation path. The empty vector allocation is recovered after non-cancelled I/O.
883    #[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// ── SinkConnector implementation ────────────────────────────────────
943
944#[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        // Append-only writes commute across independent runtime writers. Mutable writes do not:
965        // without key-affine placement and a fenced handoff, an older upsert/delete from one node
966        // can land after a newer value from another node. Keep those modes singleton until the
967        // runtime can prove that stronger topology protocol.
968        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            // Direct programmatic construction supplies the schema to `new`.
983            self.config.validate()?;
984            validate_sink_schema(&self.schema, &self.config)?;
985        } else {
986            self.apply_connector_config(config)?;
987        }
988
989        // Complete all deterministic admission before touching the network.
990        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        // Validate connectivity.
1001        let client = pool.get().await.map_err(|e| {
1002            ConnectorError::ConnectionFailed(format!("initial connection failed: {e}"))
1003        })?;
1004
1005        // Auto-create target table.
1006        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
1118// ── Helper functions ────────────────────────────────────────────────
1119
1120fn 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
1131/// Returns writable fields, excluding only the defined changelog metadata columns.
1132fn 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
1140/// Builds a schema containing only user-visible columns.
1141fn 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
1324/// Filters a `RecordBatch` to include only rows at the given indices.
1325///
1326/// Also strips the exact changelog metadata field set from the output.
1327fn 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
1372/// Strips only the defined changelog metadata columns from a `RecordBatch`.
1373fn 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
1402/// Collapse one ordinary upsert flush to the last-arriving row per configured
1403/// key, then remove the normalized changelog marker before binding UNNEST
1404/// parameters. `PostgreSQL` rejects duplicate conflict keys in one statement.
1405fn 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/// Executes an UNNEST-based INSERT/UPSERT using Arrow column arrays as parameters.
1414#[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, &param_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/// Validates a single batch before mutation, then reports whether already-buffered input must be
1459/// flushed before admission. Equality is allowed; addition fails closed on overflow.
1460#[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    // ── Constructor tests ──
1570
1571    #[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    // ── SQL generation tests ──
1659
1660    #[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        // Composite PK must match tuple-wise (UNNEST zips $1/$2 positionally), NOT the
1814        // cross-product `id = ANY($1) AND name = ANY($2)` which over-deletes (CN-2).
1815        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    // ── Changelog splitting tests ──
1882
1883    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    // ── Buffering tests ──
2047
2048    #[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    // ── Contract tests ──
2194
2195    #[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    // ── Debug output test ──
2268
2269    #[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    // ── Helper function tests ──
2278
2279    #[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}