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