Skip to main content

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