Skip to main content

laminar_connectors/kafka/sink/
mod.rs

1//! Kafka sink connector.
2
3use std::fmt::Write as _;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use arrow_array::{Array, StringArray};
8use arrow_schema::SchemaRef;
9use async_trait::async_trait;
10use rdkafka::error::{KafkaError, RDKafkaErrorCode};
11use rdkafka::message::OwnedHeaders;
12use rdkafka::producer::{DeliveryFuture, FutureProducer, FutureRecord, Producer};
13use rdkafka::ClientConfig;
14use tracing::{debug, info, warn};
15
16use crate::config::{ConnectorConfig, ConnectorState};
17use crate::connector::{
18    ConnectorTaskOwner, ConnectorTaskTracker, SinkConnector, SinkConsistency, SinkContract,
19    SinkInputMode, SinkTopology, WriteResult,
20};
21use crate::error::ConnectorError;
22use crate::serde::{Format, RecordSerializer};
23
24use super::metadata_error::{fetch_error, invalid_response, topic_error};
25use super::partitioner::{
26    KafkaPartitioner, KeyHashPartitioner, RoundRobinPartitioner, StickyPartitioner,
27};
28use super::schema_registry::SchemaRegistryClient;
29use super::sink_config::{KafkaSinkConfig, PartitionStrategy, SinkEnvelope};
30use super::sink_metrics::KafkaSinkMetrics;
31
32mod failure;
33mod keys;
34mod lifecycle;
35mod serialization;
36mod upsert;
37
38use failure::{
39    kafka_write_timeout, producer_creation_error, queue_retry_delay, record_failure,
40    unresolved_delivery_error, validate_payload_cardinality, KafkaFailure, KafkaFailureCertainty,
41    KafkaFailureScope, QUEUE_RETRY_TIMEOUT,
42};
43use keys::KeyBuffer;
44use serialization::select_serializer;
45use upsert::{project_upsert_values, validate_upsert_keys};
46
47fn required_producer(producer: Option<&FutureProducer>) -> Result<&FutureProducer, ConnectorError> {
48    producer.ok_or_else(|| ConnectorError::InvalidState {
49        expected: "producer initialized".into(),
50        actual: "producer is None".into(),
51    })
52}
53
54/// Kafka sink connector that writes Arrow `RecordBatch` data to Kafka topics.
55///
56/// Operates in Ring 1 (background) receiving data from Ring 0 via the
57/// subscription API.
58///
59/// # Lifecycle
60///
61/// 1. Create with [`KafkaSink::new`]
62/// 2. Call `open()` to create the producer and connect to Kafka
63/// 3. `write_batch()` serializes and produces records; checkpoint flushes provide
64///    durable at-least-once delivery
65/// 4. Call `close()` for clean shutdown
66///
67/// This connector deliberately rejects exactly-once admission because it does
68/// not expose a coordinated external checkpoint namespace/cursor.
69pub struct KafkaSink {
70    /// rdkafka producer (set during `open()`).
71    producer: Option<FutureProducer>,
72    /// Parsed Kafka sink configuration.
73    config: KafkaSinkConfig,
74    /// Format-specific serializer.
75    serializer: Box<dyn RecordSerializer>,
76    /// Partitioner for determining target partitions.
77    partitioner: Box<dyn KafkaPartitioner>,
78    /// Connector lifecycle state.
79    state: ConnectorState,
80    /// Dead letter queue producer (separate, non-transactional).
81    dlq_producer: Option<FutureProducer>,
82    /// Production metrics.
83    metrics: KafkaSinkMetrics,
84    /// Arrow schema for input batches.
85    schema: SchemaRef,
86    /// Optional Schema Registry client.
87    schema_registry: Option<Arc<SchemaRegistryClient>>,
88    /// Shared Avro schema ID (updated after SR registration).
89    avro_schema_id: Arc<std::sync::atomic::AtomicU32>,
90    /// Cached topic partition count (queried from broker metadata after open).
91    topic_partition_count: Option<i32>,
92    /// Sole admission authority for detached producer destruction.
93    task_owner: ConnectorTaskOwner,
94    /// Cloneable terminal observer returned to the connector runtime.
95    task_tracker: ConnectorTaskTracker,
96}
97
98impl KafkaSink {
99    /// Creates a new Kafka sink connector with explicit schema.
100    ///
101    /// # Panics
102    ///
103    /// Panics if `config.format` is not a supported serialization format.
104    /// Call [`KafkaSinkConfig::validate`] first to catch this at config time.
105    #[must_use]
106    pub fn new(
107        schema: SchemaRef,
108        config: KafkaSinkConfig,
109        registry: Option<&prometheus::Registry>,
110    ) -> Self {
111        let avro_schema_id = Arc::new(std::sync::atomic::AtomicU32::new(0));
112        let serializer =
113            select_serializer(config.format, &schema, Arc::clone(&avro_schema_id), None)
114                .expect("format validated in KafkaSinkConfig::validate()");
115        let partitioner = select_partitioner(config.partitioner);
116        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
117
118        Self {
119            producer: None,
120            config,
121            serializer,
122            partitioner,
123            state: ConnectorState::Created,
124            dlq_producer: None,
125            metrics: KafkaSinkMetrics::new(registry),
126            schema,
127            schema_registry: None,
128            avro_schema_id,
129            topic_partition_count: None,
130            task_owner,
131            task_tracker,
132        }
133    }
134
135    /// Creates a new Kafka sink with Schema Registry integration.
136    ///
137    /// # Panics
138    ///
139    /// Panics if `config.format` is not a supported serialization format.
140    /// Call [`KafkaSinkConfig::validate`] first to catch this at config time.
141    #[must_use]
142    pub fn with_schema_registry(
143        schema: SchemaRef,
144        config: KafkaSinkConfig,
145        sr_client: SchemaRegistryClient,
146    ) -> Self {
147        let sr = Arc::new(sr_client);
148        let avro_schema_id = Arc::new(std::sync::atomic::AtomicU32::new(0));
149        let serializer = select_serializer(
150            config.format,
151            &schema,
152            Arc::clone(&avro_schema_id),
153            Some(Arc::clone(&sr)),
154        )
155        .expect("format validated in KafkaSinkConfig::validate()");
156        let partitioner = select_partitioner(config.partitioner);
157        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
158
159        Self {
160            producer: None,
161            config,
162            serializer,
163            partitioner,
164            state: ConnectorState::Created,
165            dlq_producer: None,
166            metrics: KafkaSinkMetrics::new(None),
167            schema,
168            schema_registry: Some(sr),
169            avro_schema_id,
170            topic_partition_count: None,
171            task_owner,
172            task_tracker,
173        }
174    }
175
176    /// Lifecycle state (Created → Running → Closed).
177    #[must_use]
178    pub fn state(&self) -> ConnectorState {
179        self.state
180    }
181
182    /// Whether Avro schema registration is available.
183    #[must_use]
184    pub fn has_schema_registry(&self) -> bool {
185        self.schema_registry.is_some()
186    }
187
188    fn serialize_payloads(
189        &self,
190        batch: &arrow_array::RecordBatch,
191    ) -> Result<Vec<Vec<u8>>, ConnectorError> {
192        let payloads = self.serializer.serialize(batch).map_err(|error| {
193            self.metrics.record_serialization_error();
194            ConnectorError::Serde(error)
195        })?;
196        validate_payload_cardinality(batch.num_rows(), payloads.len())?;
197        Ok(payloads)
198    }
199
200    fn required_partition_count(&self) -> Result<i32, ConnectorError> {
201        self.topic_partition_count
202            .ok_or_else(|| ConnectorError::InvalidState {
203                expected: "broker topic metadata installed".into(),
204                actual: "partition count is unavailable".into(),
205            })
206    }
207
208    /// Contiguous key buffer: all key bytes in one allocation with per-row offsets.
209    ///
210    /// Returns `None` if no key column is configured.
211    fn extract_keys(
212        &self,
213        batch: &arrow_array::RecordBatch,
214    ) -> Result<Option<KeyBuffer>, ConnectorError> {
215        let Some(key_col) = &self.config.key_column else {
216            return Ok(None);
217        };
218
219        let col_idx = batch.schema().index_of(key_col).map_err(|_| {
220            ConnectorError::ConfigurationError(format!(
221                "key column '{key_col}' not found in schema"
222            ))
223        })?;
224
225        let array = batch.column(col_idx);
226        let num_rows = batch.num_rows();
227        let mut buf = KeyBuffer::with_capacity(num_rows, 32);
228
229        // Try to get string values; fall back to display representation.
230        if let Some(str_array) = array.as_any().downcast_ref::<StringArray>() {
231            for i in 0..num_rows {
232                if str_array.is_null(i) {
233                    buf.push_empty();
234                } else {
235                    buf.push(str_array.value(i).as_bytes());
236                }
237            }
238        } else {
239            use std::fmt::Write;
240            let formatter = arrow_cast::display::ArrayFormatter::try_new(
241                array,
242                &arrow_cast::display::FormatOptions::default(),
243            )
244            .map_err(|e| {
245                ConnectorError::Internal(format!(
246                    "failed to create array formatter for key column: {e}"
247                ))
248            })?;
249            let mut fmt_buf = String::with_capacity(64);
250            for i in 0..num_rows {
251                if array.is_null(i) {
252                    buf.push_empty();
253                } else {
254                    fmt_buf.clear();
255                    let _ = write!(fmt_buf, "{}", formatter.value(i));
256                    buf.push(fmt_buf.as_bytes());
257                }
258            }
259        }
260
261        Ok(Some(buf))
262    }
263
264    /// Enqueues a definitely rejected record to the dead letter queue. The
265    /// caller retains and drains the returned delivery future with all other
266    /// accepted main/DLQ records from the write.
267    async fn enqueue_dlq(
268        &self,
269        payload: &[u8],
270        key: Option<&[u8]>,
271        error_msg: &str,
272        queue_deadline: Instant,
273    ) -> Result<DeliveryFuture, KafkaFailure> {
274        let dlq_producer = self.dlq_producer.as_ref().ok_or_else(|| KafkaFailure {
275            certainty: KafkaFailureCertainty::DefinitelyNotPersisted,
276            scope: KafkaFailureScope::Connector,
277            detail: "DLQ topic or producer is not configured".into(),
278            retryable: false,
279        })?;
280        let dlq_topic = self.config.dlq_topic.as_ref().ok_or_else(|| KafkaFailure {
281            certainty: KafkaFailureCertainty::DefinitelyNotPersisted,
282            scope: KafkaFailureScope::Connector,
283            detail: "DLQ topic or producer is not configured".into(),
284            retryable: false,
285        })?;
286
287        let now = std::time::SystemTime::now()
288            .duration_since(std::time::UNIX_EPOCH)
289            .unwrap_or_else(|_| {
290                tracing::warn!("system clock before Unix epoch — using 0 for DLQ timestamp");
291                std::time::Duration::ZERO
292            })
293            .as_millis()
294            .to_string();
295        let headers = OwnedHeaders::new()
296            .insert(rdkafka::message::Header {
297                key: "__dlq.error",
298                value: Some(error_msg.as_bytes()),
299            })
300            .insert(rdkafka::message::Header {
301                key: "__dlq.topic",
302                value: Some(self.config.topic.as_bytes()),
303            })
304            .insert(rdkafka::message::Header {
305                key: "__dlq.timestamp",
306                value: Some(now.as_bytes()),
307            });
308
309        let mut record = FutureRecord::to(dlq_topic)
310            .payload(payload)
311            .headers(headers);
312
313        if let Some(k) = key {
314            record = record.key(k);
315        }
316
317        Self::enqueue_with_queue_retry(dlq_producer, record, queue_deadline)
318            .await
319            .map_err(|error| KafkaFailure::enqueue(&error, "Kafka DLQ"))
320    }
321
322    /// Synchronously enqueue a record with a short retry on `QueueFull`.
323    /// Uses `send_result` rather than `send`, because the latter is
324    /// `async fn` in rdkafka 0.39+ and only enqueues when polled — which
325    /// would defeat the Vec-of-futures pipelining in `write_batch`.
326    async fn enqueue_with_queue_retry(
327        producer: &FutureProducer,
328        mut record: FutureRecord<'_, [u8], [u8]>,
329        queue_deadline: Instant,
330    ) -> Result<DeliveryFuture, KafkaError> {
331        loop {
332            // The deadline limits waiting, not the immediate enqueue attempt.
333            match producer.send_result(record) {
334                Ok(fut) => return Ok(fut),
335                Err((error @ KafkaError::MessageProduction(RDKafkaErrorCode::QueueFull), r)) => {
336                    let Some(delay) = queue_retry_delay(queue_deadline, Instant::now()) else {
337                        return Err(error);
338                    };
339                    record = r;
340                    tokio::time::sleep(delay).await;
341                }
342                Err((error, _)) => return Err(error),
343            }
344        }
345    }
346
347    /// Upsert-envelope produce: collapse the Z-set changelog to one record per merge key, then
348    /// emit a keyed value for a live group (`_op = U`) or a null-value tombstone for a removed group
349    /// (`_op = D`). The topic must be log-compacted and keyed on the merge key for the tombstones to
350    /// GC and for the latest-per-key state to be recoverable from offset 0.
351    #[allow(clippy::cast_possible_truncation)]
352    async fn write_upsert_batch(
353        &mut self,
354        batch: &arrow_array::RecordBatch,
355    ) -> Result<WriteResult, ConnectorError> {
356        let collapsed = self.collapse_upsert_changelog(batch)?;
357        let rows = collapsed.num_rows();
358        if rows == 0 {
359            return Ok(WriteResult::new(0, 0));
360        }
361        let (value_batch, ops) = project_upsert_values(&collapsed)?;
362
363        self.ensure_schema_ready(&value_batch.schema()).await?;
364        let payloads = self.serialize_payloads(&value_batch)?;
365        let keys = self.extract_keys(&collapsed)?;
366        // Reject empty/NULL merge keys before producing ANY record: a compacted topic can't
367        // represent an unkeyed row, and a mid-loop bail would leave earlier rows already enqueued.
368        validate_upsert_keys(keys.as_ref(), payloads.len())?;
369
370        let partition_count = self.required_partition_count()?;
371        let producer = required_producer(self.producer.as_ref())?;
372
373        let mut delivery_futures = Vec::with_capacity(rows);
374        let mut enqueue_failure: Option<(KafkaFailure, usize)> = None;
375        let queue_deadline = Instant::now() + QUEUE_RETRY_TIMEOUT;
376        for (i, payload) in payloads.iter().enumerate() {
377            let key: Option<&[u8]> = keys.as_ref().map(|kb| kb.key(i));
378            let is_delete = ops.value(i) == "D";
379            let partition = self.partitioner.partition(key, partition_count);
380            // No `.payload()` for a delete → null value = Kafka tombstone.
381            let mut record: FutureRecord<'_, [u8], [u8]> = FutureRecord::to(&self.config.topic);
382            if let Some(k) = key {
383                record = record.key(k);
384            }
385            if !is_delete {
386                record = record.payload(payload.as_slice());
387            }
388            if let Some(p) = partition {
389                record = record.partition(p);
390            }
391            match Self::enqueue_with_queue_retry(producer, record, queue_deadline).await {
392                Ok(future) => delivery_futures.push((Instant::now(), future, is_delete, i)),
393                Err(error) => {
394                    let mut failure = KafkaFailure::enqueue(&error, "Kafka upsert");
395                    let affected = rows - i;
396                    if affected > 1 {
397                        let _ = write!(
398                            failure.detail,
399                            "; {} later record(s) were not attempted",
400                            affected - 1
401                        );
402                    }
403                    enqueue_failure = Some((failure, affected));
404                    break;
405                }
406            }
407        }
408
409        let mut records_written: usize = 0;
410        let mut bytes_written: u64 = 0;
411        let mut definitely_not_persisted: usize = 0;
412        let mut ambiguous: usize = 0;
413        let mut first_error: Option<String> = None;
414        let mut retryable = true;
415        for (send_time, future, is_delete, i) in delivery_futures {
416            match future.await {
417                Ok(Ok(_)) => {
418                    self.metrics
419                        .record_produce_latency(send_time.elapsed().as_micros() as u64);
420                    records_written += 1;
421                    if !is_delete {
422                        bytes_written += payloads[i].len() as u64;
423                    }
424                }
425                Ok(Err((err, _))) => {
426                    let failure = KafkaFailure::delivery(&err, "Kafka upsert");
427                    record_failure(
428                        &failure,
429                        1,
430                        &mut definitely_not_persisted,
431                        &mut ambiguous,
432                        &mut first_error,
433                        &mut retryable,
434                    );
435                }
436                Err(_canceled) => {
437                    let failure = KafkaFailure::canceled("Kafka upsert");
438                    record_failure(
439                        &failure,
440                        1,
441                        &mut definitely_not_persisted,
442                        &mut ambiguous,
443                        &mut first_error,
444                        &mut retryable,
445                    );
446                }
447            }
448        }
449        if let Some((failure, affected)) = enqueue_failure {
450            record_failure(
451                &failure,
452                affected,
453                &mut definitely_not_persisted,
454                &mut ambiguous,
455                &mut first_error,
456                &mut retryable,
457            );
458        }
459        self.metrics
460            .record_write(records_written as u64, bytes_written);
461        if definitely_not_persisted > 0 || ambiguous > 0 {
462            self.metrics.record_error();
463            return Err(unresolved_delivery_error(
464                "upsert produce",
465                rows,
466                records_written,
467                definitely_not_persisted,
468                ambiguous,
469                first_error,
470                retryable,
471            ));
472        }
473        Ok(WriteResult::new(records_written, bytes_written))
474    }
475}
476
477#[async_trait]
478impl SinkConnector for KafkaSink {
479    fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
480        Some(self.task_tracker.clone())
481    }
482
483    fn contract(&self, config: &ConnectorConfig) -> Result<SinkContract, ConnectorError> {
484        let cfg = if config.properties().is_empty() {
485            self.config.clone()
486        } else {
487            KafkaSinkConfig::from_config(config)?
488        };
489        // Kafka acknowledges these idempotent writes with all in-sync replicas,
490        // but this connector does not expose an external checkpoint cursor.
491        let consistency = SinkConsistency::DurableAtLeastOnce;
492        // Append records from independent writers compose safely. Upsert records do not carry a
493        // fenced generation, so an old writer can otherwise overwrite a newer value for the same
494        // compacted key after ownership handoff.
495        let topology = if cfg.envelope == SinkEnvelope::Upsert {
496            SinkTopology::Singleton
497        } else {
498            SinkTopology::MultiWriter
499        };
500        let input_mode = if cfg.envelope == SinkEnvelope::Upsert {
501            SinkInputMode::FullChangelog
502        } else {
503            SinkInputMode::AppendOnly
504        };
505        Ok(SinkContract::new(consistency, topology, input_mode))
506    }
507
508    async fn open(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
509        self.state = ConnectorState::Initializing;
510
511        if !config.properties().is_empty() {
512            let parsed = KafkaSinkConfig::from_config(config)?;
513            self.config = parsed;
514            self.serializer = select_serializer(
515                self.config.format,
516                &self.schema,
517                Arc::clone(&self.avro_schema_id),
518                self.schema_registry.clone(),
519            )?;
520            self.partitioner = select_partitioner(self.config.partitioner);
521        }
522        self.config.validate()?;
523        info!(
524            brokers = %self.config.bootstrap_servers,
525            topic = %self.config.topic,
526            format = %self.config.format,
527            "opening Kafka sink connector"
528        );
529
530        if let Some(ref url) = self.config.schema_registry_url {
531            if self.schema_registry.is_none() {
532                let sr = if let Some(ref ca_path) = self.config.schema_registry_ssl_ca_location {
533                    SchemaRegistryClient::with_tls(
534                        url,
535                        self.config.schema_registry_auth.clone(),
536                        ca_path,
537                    )?
538                } else {
539                    SchemaRegistryClient::new(url, self.config.schema_registry_auth.clone())?
540                };
541                self.schema_registry = Some(Arc::new(sr));
542            }
543        }
544
545        // Schema registration is deferred to the first write_batch(), where the real pipeline
546        // output schema is known — the factory default is a placeholder that would pollute the
547        // registry and break compat checks.
548        if self.config.format == Format::Avro {
549            if let Some(ref sr) = self.schema_registry {
550                if let Some(ref compat) = self.config.schema_compatibility {
551                    let subject = format!("{}-value", self.config.topic);
552                    sr.set_compatibility_level(&subject, *compat).await?;
553                }
554            }
555        }
556
557        let rdkafka_config: ClientConfig = self.config.to_rdkafka_config();
558        let producer: FutureProducer = rdkafka_config
559            .create()
560            .map_err(|error| producer_creation_error("main", &error))?;
561        self.producer = Some(producer);
562
563        // Keep DLQ production decoupled from the main producer. Once a producer
564        // exists it lives in `self`, so every later error and cancellation is
565        // routed through tracked off-runtime teardown.
566        if self.config.dlq_topic.is_some() {
567            let dlq_config = self.config.to_dlq_rdkafka_config();
568            match dlq_config.create::<FutureProducer>() {
569                Ok(dlq_producer) => self.dlq_producer = Some(dlq_producer),
570                Err(error) => {
571                    self.retire_producers();
572                    return Err(producer_creation_error("DLQ", &error));
573                }
574            }
575        }
576
577        // Clear stale metadata before lookup. Custom routing must never run
578        // against an assumed partition count after a failed reopen.
579        self.topic_partition_count = None;
580        let producer = self
581            .producer
582            .as_ref()
583            .expect("Kafka producer was installed above")
584            .clone();
585        let topic = self.config.topic.clone();
586        let metadata_guard = self
587            .task_owner
588            .track()
589            .expect("live Kafka sink must admit its metadata lookup");
590        let metadata = tokio::task::spawn_blocking(move || -> Result<i32, ConnectorError> {
591            let _metadata_guard = metadata_guard;
592            let metadata = producer
593                .client()
594                .fetch_metadata(Some(&topic), Duration::from_secs(5))
595                .map_err(|error| fetch_error(&topic, &error))?;
596            let topic_metadata = metadata
597                .topics()
598                .iter()
599                .find(|candidate| candidate.name() == topic.as_str())
600                .ok_or_else(|| invalid_response(&topic, "metadata response omitted the topic"))?;
601            if let Some(error) = topic_metadata.error() {
602                return Err(topic_error(&topic, error.into()));
603            }
604            i32::try_from(topic_metadata.partitions().len())
605                .ok()
606                .filter(|count| *count > 0)
607                .ok_or_else(|| invalid_response(&topic, "broker returned no partitions"))
608        })
609        .await;
610        match metadata {
611            Ok(Ok(count)) => {
612                self.topic_partition_count = Some(count);
613                info!(
614                    topic = %self.config.topic,
615                    partitions = count,
616                    "queried topic partition count from broker"
617                );
618            }
619            Ok(Err(error)) => {
620                self.retire_producers();
621                return Err(error);
622            }
623            Err(error) => {
624                self.retire_producers();
625                return Err(ConnectorError::Internal(format!(
626                    "Kafka metadata worker for topic '{}' failed: {error}",
627                    self.config.topic
628                )));
629            }
630        }
631
632        self.state = ConnectorState::Running;
633        info!("Kafka sink connector opened successfully");
634        Ok(())
635    }
636
637    #[allow(clippy::cast_possible_truncation)]
638    // Record batch row/byte counts fit in narrower types
639    // WHY: Keep main enqueue, DLQ enqueue, and both delivery drains in one chronological protocol.
640    // Splitting these phases across async helpers would add hot-path futures and make it harder to
641    // audit which accepted deliveries are drained before an outcome-unknown error is returned.
642    #[allow(clippy::too_many_lines)]
643    async fn write_batch(
644        &mut self,
645        batch: &arrow_array::RecordBatch,
646    ) -> Result<WriteResult, ConnectorError> {
647        if self.state != ConnectorState::Running {
648            return Err(ConnectorError::InvalidState {
649                expected: "Running".into(),
650                actual: self.state.to_string(),
651            });
652        }
653
654        // Upsert envelope: collapse the Z-set changelog per merge key and produce keyed records
655        // (live groups) + null-value tombstones (removed groups). See `write_upsert_batch`.
656        if self.config.envelope == SinkEnvelope::Upsert {
657            return self.write_upsert_batch(batch).await;
658        }
659
660        self.ensure_schema_ready(&batch.schema()).await?;
661
662        let payloads = self.serialize_payloads(batch)?;
663
664        let keys = self.extract_keys(batch)?;
665        let partition_count = self.required_partition_count()?;
666        let producer = required_producer(self.producer.as_ref())?;
667
668        // Phase 1: enqueue every record into librdkafka's bounded internal queue.
669        // A record-local terminal rejection may continue only when it can be
670        // routed to DLQ. Every other enqueue error stops new dispatch, but all
671        // delivery futures already accepted by librdkafka are still drained.
672        let mut delivery_futures = Vec::with_capacity(payloads.len());
673        let mut dlq_candidates = Vec::new();
674        let mut enqueue_failure: Option<(KafkaFailure, usize)> = None;
675        let main_queue_deadline = Instant::now() + QUEUE_RETRY_TIMEOUT;
676        for (i, payload) in payloads.iter().enumerate() {
677            let key: Option<&[u8]> = keys.as_ref().map(|kb| kb.key(i)).filter(|k| !k.is_empty());
678            let partition = self.partitioner.partition(key, partition_count);
679
680            let mut record = FutureRecord::to(&self.config.topic).payload(payload.as_slice());
681            if let Some(k) = key {
682                record = record.key(k);
683            }
684            if let Some(p) = partition {
685                record = record.partition(p);
686            }
687
688            match Self::enqueue_with_queue_retry(producer, record, main_queue_deadline).await {
689                Ok(future) => delivery_futures.push((i, Instant::now(), future)),
690                Err(error) => {
691                    self.metrics.record_error();
692                    let mut failure = KafkaFailure::enqueue(&error, "Kafka");
693                    if self.dlq_producer.is_some() && failure.dlq_eligible() {
694                        dlq_candidates.push((i, failure.detail));
695                        continue;
696                    }
697
698                    let affected = payloads.len() - i;
699                    if affected > 1 {
700                        let _ = write!(
701                            failure.detail,
702                            "; {} later record(s) were not attempted",
703                            affected - 1
704                        );
705                    }
706                    enqueue_failure = Some((failure, affected));
707                    break;
708                }
709            }
710        }
711
712        // Enqueue every DLQ candidate before awaiting any delivery report. Both
713        // producers then make progress concurrently on their polling threads,
714        // so draining the future vectors below does not multiply the driver
715        // delivery deadline by the number of rejected rows.
716        let dlq_candidate_count = dlq_candidates.len();
717        let mut dlq_delivery_futures = Vec::with_capacity(dlq_candidate_count);
718        let mut dlq_enqueue_failure: Option<(KafkaFailure, usize)> = None;
719        let dlq_queue_deadline = Instant::now() + QUEUE_RETRY_TIMEOUT;
720        for (position, (row, original_error)) in dlq_candidates.into_iter().enumerate() {
721            let key = keys
722                .as_ref()
723                .map(|buffer| buffer.key(row))
724                .filter(|key| !key.is_empty());
725            match self
726                .enqueue_dlq(&payloads[row], key, &original_error, dlq_queue_deadline)
727                .await
728            {
729                Ok(future) => dlq_delivery_futures.push((row, original_error, future)),
730                Err(mut failure) => {
731                    let affected = dlq_candidate_count - position;
732                    if affected > 1 {
733                        let _ = write!(
734                            failure.detail,
735                            "; {} later DLQ record(s) were not attempted",
736                            affected - 1
737                        );
738                    }
739                    dlq_enqueue_failure = Some((failure, affected));
740                    break;
741                }
742            }
743        }
744
745        let mut records_written: usize = 0;
746        let mut bytes_written: u64 = 0;
747        let mut definitely_not_persisted: usize = 0;
748        let mut ambiguous: usize = 0;
749        let mut dlq_records: usize = 0;
750        let mut dlq_bytes: u64 = 0;
751        let mut first_error: Option<String> = None;
752        let mut retryable = true;
753        for (row, send_time, future) in delivery_futures {
754            match future.await {
755                Ok(Ok(_)) => {
756                    let latency_us = send_time.elapsed().as_micros() as u64;
757                    self.metrics.record_produce_latency(latency_us);
758                    records_written += 1;
759                    bytes_written += payloads[row].len() as u64;
760                }
761                Ok(Err((error, _))) => {
762                    self.metrics.record_error();
763                    let failure = KafkaFailure::delivery(&error, "Kafka");
764                    record_failure(
765                        &failure,
766                        1,
767                        &mut definitely_not_persisted,
768                        &mut ambiguous,
769                        &mut first_error,
770                        &mut retryable,
771                    );
772                }
773                Err(_) => {
774                    self.metrics.record_error();
775                    let failure = KafkaFailure::canceled("Kafka");
776                    record_failure(
777                        &failure,
778                        1,
779                        &mut definitely_not_persisted,
780                        &mut ambiguous,
781                        &mut first_error,
782                        &mut retryable,
783                    );
784                }
785            }
786        }
787        for (row, original_error, future) in dlq_delivery_futures {
788            match future.await {
789                Ok(Ok(_)) => {
790                    self.metrics.record_dlq();
791                    dlq_records += 1;
792                    dlq_bytes += payloads[row].len() as u64;
793                }
794                Ok(Err((error, _))) => {
795                    self.metrics.record_error();
796                    let mut failure = KafkaFailure::delivery(&error, "Kafka DLQ");
797                    failure.detail = format!("original: {original_error}; {}", failure.detail);
798                    warn!(
799                        original_error = %original_error,
800                        dlq_error = %failure.detail,
801                        "failed to route definitely rejected Kafka record to DLQ"
802                    );
803                    record_failure(
804                        &failure,
805                        1,
806                        &mut definitely_not_persisted,
807                        &mut ambiguous,
808                        &mut first_error,
809                        &mut retryable,
810                    );
811                }
812                Err(_) => {
813                    self.metrics.record_error();
814                    let mut failure = KafkaFailure::canceled("Kafka DLQ");
815                    failure.detail = format!("original: {original_error}; {}", failure.detail);
816                    record_failure(
817                        &failure,
818                        1,
819                        &mut definitely_not_persisted,
820                        &mut ambiguous,
821                        &mut first_error,
822                        &mut retryable,
823                    );
824                }
825            }
826        }
827        if let Some((failure, affected)) = dlq_enqueue_failure {
828            record_failure(
829                &failure,
830                affected,
831                &mut definitely_not_persisted,
832                &mut ambiguous,
833                &mut first_error,
834                &mut retryable,
835            );
836        }
837        if let Some((failure, affected)) = enqueue_failure {
838            record_failure(
839                &failure,
840                affected,
841                &mut definitely_not_persisted,
842                &mut ambiguous,
843                &mut first_error,
844                &mut retryable,
845            );
846        }
847
848        self.metrics
849            .record_write(records_written as u64, bytes_written);
850
851        debug!(
852            records = records_written,
853            dlq_records,
854            bytes = bytes_written,
855            definitely_not_persisted,
856            ambiguous,
857            "wrote batch to Kafka"
858        );
859
860        let applied = records_written + dlq_records;
861        if definitely_not_persisted > 0 || ambiguous > 0 {
862            return Err(unresolved_delivery_error(
863                "produce",
864                payloads.len(),
865                applied,
866                definitely_not_persisted,
867                ambiguous,
868                first_error,
869                retryable,
870            ));
871        }
872
873        Ok(WriteResult::new(applied, bytes_written + dlq_bytes))
874    }
875
876    fn schema(&self) -> SchemaRef {
877        self.schema.clone()
878    }
879
880    fn suggested_write_timeout(&self) -> Duration {
881        // The runtime deadline must dominate librdkafka's delivery deadline.
882        // Main and DLQ deliveries are concurrent, so only bounded queue retry
883        // and scheduling headroom are added rather than a per-row multiplier.
884        kafka_write_timeout(self.config.delivery_timeout)
885    }
886
887    async fn flush(&mut self) -> Result<(), ConnectorError> {
888        // Every successful write_batch awaits every delivery report (including
889        // DLQ delivery). There can be no acknowledged connector write with
890        // producer work still in flight, so a blocking librdkafka flush adds no
891        // checkpoint durability and is unsafe to cancel on generation retirement.
892        Ok(())
893    }
894
895    async fn close(&mut self) -> Result<(), ConnectorError> {
896        info!("closing Kafka sink connector");
897
898        // Completed writes have already observed delivery reports. A cancelled
899        // write retires the whole producer generation and is replayed from the
900        // engine checkpoint, so close must not start another blocking flush.
901        self.retire_producers();
902        self.state = ConnectorState::Closed;
903        info!("Kafka sink connector closed");
904        Ok(())
905    }
906}
907
908impl Drop for KafkaSink {
909    fn drop(&mut self) {
910        self.retire_producers();
911    }
912}
913
914impl std::fmt::Debug for KafkaSink {
915    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
916        f.debug_struct("KafkaSink")
917            .field("state", &self.state)
918            .field("topic", &self.config.topic)
919            .field("format", &self.config.format)
920            .finish_non_exhaustive()
921    }
922}
923
924/// Selects the appropriate partitioner for the given strategy.
925fn select_partitioner(strategy: PartitionStrategy) -> Box<dyn KafkaPartitioner> {
926    match strategy {
927        PartitionStrategy::KeyHash => Box::new(KeyHashPartitioner::new()),
928        PartitionStrategy::RoundRobin => Box::new(RoundRobinPartitioner::new()),
929        PartitionStrategy::Sticky => Box::new(StickyPartitioner::new(100)),
930    }
931}
932
933#[cfg(test)]
934mod tests;