Skip to main content

laminar_connectors/kafka/source/
mod.rs

1//! Kafka source connector: consumes topics via rdkafka's `StreamConsumer`,
2//! deserializes with pluggable formats, and yields Arrow `RecordBatch`es.
3
4use arrow_array::builder::BinaryBuilder;
5use arrow_array::{Array, RecordBatch, RecordBatchOptions, StringArray, UInt32Array};
6use arrow_schema::{DataType, SchemaRef};
7use async_trait::async_trait;
8use rdkafka::consumer::{CommitMode, Consumer, StreamConsumer};
9use rdkafka::error::KafkaError;
10use rdkafka::message::Message;
11use rdkafka::ClientConfig;
12use rdkafka::TopicPartitionList;
13use std::num::NonZeroU64;
14use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, AtomicUsize, Ordering};
15use std::sync::{Arc, Mutex};
16use tokio::sync::{Mutex as AsyncMutex, Notify};
17use tracing::{debug, info, warn};
18
19use crate::checkpoint::{SourceCheckpoint, SourceCheckpointDelta};
20use crate::config::{ConnectorConfig, ConnectorState};
21use crate::connector::{
22    ConnectorTaskGuard, ConnectorTaskOwner, ConnectorTaskTracker, DeliveryGuarantee, SourceBatch,
23    SourceConnector, SourceConsistency, SourceContract, SourceDrainOutcome, SourceDrainRequest,
24    SourceDrainResolution, SourceInputMode, SourceMutation, SourcePosition,
25    SourceRowPositionCapability, SourceRowPositions, SourceStart, SourceTopology,
26};
27use crate::error::{ConnectorError, SerdeError};
28use crate::serde::{self, Format, RecordDeserializer};
29
30use super::avro::AvroDeserializer;
31use super::config::{
32    KafkaSourceConfig, OffsetReset, SchemaEvolutionStrategy, StartupMode, TopicSubscription,
33};
34use super::metadata_error::{fetch_error, invalid_response, topic_error};
35use super::metrics::KafkaSourceMetrics;
36use super::offsets::{OffsetTracker, KAFKA_PARTITION_BASELINE_PREFIX};
37use super::rebalance::RebalanceState;
38use super::schema_registry::SchemaRegistryClient;
39
40use super::rebalance::LaminarConsumerContext;
41use crate::schema::evolution::SchemaEvolution;
42use crate::schema::traits::{CompatibilityMode, EvolutionVerdict};
43
44mod background;
45mod checkpoint;
46mod debezium;
47mod decoding;
48mod drain;
49#[cfg(feature = "fpml")]
50pub(crate) mod fpml;
51mod initialization;
52mod lifecycle;
53mod metadata;
54mod polling;
55mod progress;
56mod reader;
57mod recency;
58mod record_metadata;
59mod startup;
60
61use record_metadata::{KafkaHeader, KafkaRowMetadata, KafkaTimestampType};
62pub use record_metadata::{KafkaMetadataColumn, KafkaMetadataColumns};
63
64use background::{
65    ensure_background_task_reaper, join_background_task, reap_last_arc_off_runtime,
66    KafkaBlockingTaskError, KafkaBlockingTasks,
67};
68use checkpoint::{
69    acquired_numeric_position, assignment_seek_tpl, build_vnode_assignment_tpl,
70    consumer_creation_error, decode_partition_baselines, deterministic_initial_offset,
71    kafka_input_channels, kafka_reader_error_is_transient, kafka_row_positions,
72    retire_accepted_rotation_baselines, rotation_baselines_len, rotation_partition_baseline,
73    startup_default_offset, tpl_of, update_rotation_baselines, validate_partition_baselines,
74    validate_positions_not_expired, validate_resume_input_channels, vnode_payload_is_current,
75    NormalizedDebeziumBatch,
76};
77use debezium::normalize_kafka_debezium_batch;
78use drain::{
79    cached_partition_vnode, kafka_bootstrap_is_unassigned, kafka_drain_partitions,
80    kafka_drain_target_ready, kafka_owned_partition_sets, kafka_partition_routes,
81    kafka_partition_set, lock_or_recover, publish_reader_fault, resolve_kafka_reader_drain,
82    terminalize_guaranteed_poll_error, try_capture_at_assignment_fence, validate_kafka_assignment,
83    validate_kafka_partition_results, KafkaAssignmentPublication, KafkaDrainBoundary,
84    KafkaDrainPartition, KafkaDrainPosition, KafkaPartitionBaselines, KafkaPartitionRoutes,
85    KafkaPartitionSet, KafkaPayload, KafkaReaderDrain, KafkaReaderDrainCommand, KafkaReaderItem,
86    KafkaReaderRx, KafkaRotationBaselines, KafkaSourceDrain, KafkaStartPlan,
87    KAFKA_BACKGROUND_CLOSE_BUDGET, KAFKA_POSITION_LOOKUP_BUDGET, KAFKA_POSITION_LOOKUP_CONCURRENCY,
88};
89use metadata::{
90    fetch_explicit_topic_metadata, fetch_partition_low_watermarks, fetch_partition_watermarks,
91    resolve_timestamp_offsets,
92};
93use progress::KafkaProgress;
94
95/// Kafka source connector that consumes messages and produces Arrow batches.
96///
97/// Operates in Ring 1 (background) and pushes deserialized `RecordBatch`
98/// data to Ring 0 via the streaming `Source<T>` API.
99///
100/// # Lifecycle
101///
102/// 1. Create with [`KafkaSource::new`] or [`KafkaSource::with_schema_registry`]
103/// 2. Call `start()` with the initial or exact recovered position
104/// 3. Call `poll_batch()` in a loop to consume messages
105/// 4. Call `checkpoint()` for fault tolerance
106/// 5. Call `close()` for clean shutdown
107///
108/// Guaranteed delivery uses engine-owned manual assignment: the complete explicit topic inventory
109/// in embedded/single-node mode or the owned vnode subset in cluster mode. Broker consumer-group
110/// ownership can revoke a partition before an asynchronous engine checkpoint captures its final
111/// offset, so dynamic group ownership is intentionally limited to `BestEffort`.
112pub struct KafkaSource {
113    consumer: Option<Arc<StreamConsumer<LaminarConsumerContext>>>,
114    config: KafkaSourceConfig,
115    /// Delivery contract selected by the engine for this connector generation.
116    /// Only `BestEffort` may discard records that fail deserialization.
117    delivery: DeliveryGuarantee,
118    deserializer: Box<dyn RecordDeserializer>,
119    /// The `FpML` decoder, started with the source for the `fpml` format.
120    #[cfg(feature = "fpml")]
121    fpml: Option<Arc<crate::fpml::FpmlDecoder>>,
122    offsets: OffsetTracker,
123    state: ConnectorState,
124    metrics: KafkaSourceMetrics,
125    metrics_registry: Option<prometheus::Registry>,
126    progress: Option<KafkaProgress>,
127    schema: SchemaRef,
128    channel_len: Arc<AtomicUsize>,
129    rebalance_state: Arc<Mutex<RebalanceState>>,
130    /// Shared rebalance counter bridging `LaminarConsumerContext` → `KafkaSourceMetrics`.
131    rebalance_counter: Arc<AtomicU64>,
132    /// Bumped on each partition revoke; `poll_batch` compares it lock-free to
133    /// detect a revoke and purge the lost partitions' offsets.
134    revoke_generation: Arc<AtomicU64>,
135    /// Bumped on each partition assign; the reader loop seeks the newly-assigned
136    /// partitions on change (see the seek block in `ensure_reader_started`).
137    assign_generation: Arc<AtomicU64>,
138    last_seen_revoke_gen: u64,
139    schema_registry: Option<Arc<SchemaRegistryClient>>,
140    data_ready: Arc<Notify>,
141    msg_rx: Option<KafkaReaderRx>,
142    reader_handle: Option<tokio::task::JoinHandle<()>>,
143    reader_shutdown: Option<tokio::sync::watch::Sender<bool>>,
144    /// Sole admission authority for detached work in this connector generation.
145    task_owner: ConnectorTaskOwner,
146    /// Cloneable terminal observer returned to the connector runtime.
147    task_tracker: ConnectorTaskTracker,
148    /// Blocking native calls owned and fenced by this connector generation.
149    blocking_tasks: KafkaBlockingTasks,
150    /// First terminal reader reason, published before an assignment wait can hide its exit.
151    reader_fault: Arc<Mutex<Option<Arc<str>>>>,
152    /// Allocated only for a cluster-assigned instance; local readers have no rotation path.
153    reader_drain_tx: Option<tokio::sync::mpsc::UnboundedSender<KafkaReaderDrainCommand>>,
154    source_drain: Option<KafkaSourceDrain>,
155    /// Offset snapshot for the rebalance callback's seek-on-assign, refreshed
156    /// once per `poll_batch()` cycle.
157    offset_snapshot: Arc<Mutex<OffsetTracker>>,
158    /// When set, every partition absent from the installed engine checkpoint is
159    /// explicitly positioned at the configured deterministic start. This must
160    /// remain armed for the connector lifetime: a later rebalance can introduce
161    /// a partition that was not present in the startup checkpoint, and using the
162    /// broker's stored group offset there would cross engine timelines.
163    deterministic_unrecorded_position: Arc<AtomicBool>,
164
165    /// Stable catalog source identity for namespaced cluster handoff offsets.
166    source_name: Arc<str>,
167
168    /// Cluster vnode assignment: when set, `start()` manually `assign()`s this
169    /// source's vnode-owned partitions instead of `subscribe()`, and the reader
170    /// re-binds on version rotation.
171    vnode_assignment: Option<(
172        Arc<laminar_core::state::VnodeRegistry>,
173        laminar_core::state::NodeId,
174    )>,
175    /// Precomputed source/topic/partition routes used by the per-record stale-owner fence.
176    vnode_partition_routes: KafkaPartitionRoutes,
177    /// Canonical non-vnode manual assignment used by local guaranteed, specific, and timestamp
178    /// starts. Checkpointing trusts this engine-owned inventory, never a fallible broker query.
179    manual_topic_partitions: KafkaPartitionSet,
180    /// Cached physical-channel inventory for stable engine-owned local assignments.
181    manual_input_channels: Arc<[Vec<u8>]>,
182    /// Concrete next-to-read position captured before intake for every engine-owned partition.
183    /// It remains authoritative until that partition has an accepted-record offset.
184    manual_partition_baselines: KafkaPartitionBaselines,
185    /// Exact Kafka ownership and durable handoff positions for one assignment version.
186    assignment_publication: Arc<Mutex<Arc<KafkaAssignmentPublication>>>,
187    /// Zero-cost steady-state fast path: avoids locking the rotation snapshot
188    /// when no acquired partition is waiting for its first accepted record.
189    rotation_partition_baseline_count: Arc<AtomicUsize>,
190    /// Rotation publication whose stale local offsets have already been removed.
191    /// Baseline publication is immutable within one assignment version, so this
192    /// keeps that cleanup off the steady-state poll path.
193    applied_rotation_baseline_version: Option<u64>,
194    /// Assignment whose complete batch cursor has already been produced.
195    batch_cursor_assignment_version: Option<u64>,
196    /// Assignment version whose Kafka consumer rebind and durable cursor
197    /// validation have completed. Barriers stay fenced while this trails the
198    /// registry's atomically published version.
199    reconciled_assignment_version: Arc<AtomicU64>,
200
201    /// Previous Avro writer schema, diffed against the next for evolution detection.
202    last_avro_schema: Option<SchemaRef>,
203
204    // Reusable poll_batch buffers — cleared each cycle, capacity retained.
205    poll_payloads: Vec<KafkaPayload>,
206    poll_payload_buf: Vec<u8>,
207    poll_payload_offsets: Vec<(usize, usize)>,
208    /// Metadata of each staged row, populated only when metadata columns are selected.
209    poll_row_metadata: Vec<KafkaRowMetadata>,
210    /// This poll's (topic, partition, offset) triples for decoded rows, folded into `offsets`
211    /// only after the complete output batch is constructed — so decode or finalization failure
212    /// cannot advance beyond data that was never emitted. Reused across polls.
213    poll_staged_offsets: Vec<(Arc<str>, i32, i64)>,
214    /// Accepted records that produce no row (a null value in an append or Debezium source).
215    /// They advance the cursor with the same batch as `poll_staged_offsets`.
216    poll_progress_offsets: Vec<(Arc<str>, i32, i64)>,
217}
218
219impl KafkaSource {
220    /// Creates a new Kafka source connector with explicit schema.
221    ///
222    /// Named progress telemetry requires a registry and `laminar.source.name` in the fully
223    /// resolved startup configuration. The database supplies this identity automatically.
224    #[must_use]
225    pub fn new(
226        schema: SchemaRef,
227        config: KafkaSourceConfig,
228        registry: Option<&prometheus::Registry>,
229    ) -> Self {
230        Self::build_base(schema, config, select_deserializer, None, registry)
231    }
232
233    /// Creates a new Kafka source connector with Schema Registry.
234    #[must_use]
235    pub fn with_schema_registry(
236        schema: SchemaRef,
237        config: KafkaSourceConfig,
238        sr_client: SchemaRegistryClient,
239    ) -> Self {
240        let sr = Arc::new(sr_client);
241        let sr_clone = Arc::clone(&sr);
242        let deser_factory = move |format: Format| -> Box<dyn RecordDeserializer> {
243            if format == Format::Avro {
244                Box::new(AvroDeserializer::with_schema_registry(sr_clone))
245            } else {
246                select_deserializer(format)
247            }
248        };
249        Self::build_base(schema, config, deser_factory, Some(sr), None)
250    }
251
252    /// Build a Schema Registry client from the parsed config, or
253    /// `Ok(None)` when `schema.registry.url` is not set.
254    fn build_sr_client(
255        config: &KafkaSourceConfig,
256    ) -> Result<Option<SchemaRegistryClient>, ConnectorError> {
257        let Some(sr_url) = config.schema_registry_url.as_ref() else {
258            return Ok(None);
259        };
260        let ca = config.schema_registry_ssl_ca_location.as_deref();
261        let certificate = config.schema_registry_ssl_certificate_location.as_deref();
262        let key = config.schema_registry_ssl_key_location.as_deref();
263        let client = if ca.is_some() || certificate.is_some() || key.is_some() {
264            SchemaRegistryClient::with_tls_mtls(
265                sr_url.clone(),
266                config.schema_registry_auth.clone(),
267                ca,
268                certificate,
269                key,
270            )?
271        } else {
272            SchemaRegistryClient::new(sr_url.clone(), config.schema_registry_auth.clone())?
273        };
274        Ok(Some(client))
275    }
276
277    fn build_base(
278        schema: SchemaRef,
279        config: KafkaSourceConfig,
280        deser_factory: impl FnOnce(Format) -> Box<dyn RecordDeserializer>,
281        schema_registry: Option<Arc<SchemaRegistryClient>>,
282        registry: Option<&prometheus::Registry>,
283    ) -> Self {
284        let deserializer = deser_factory(config.format);
285        let channel_len = Arc::new(AtomicUsize::new(0));
286        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
287        let blocking_guard = task_owner
288            .track()
289            .expect("new Kafka source task generation must be live");
290
291        Self {
292            consumer: None,
293            config,
294            delivery: DeliveryGuarantee::BestEffort,
295            deserializer,
296            offsets: OffsetTracker::new(),
297            state: ConnectorState::Created,
298            metrics: KafkaSourceMetrics::new(registry),
299            metrics_registry: registry.cloned(),
300            progress: None,
301            schema,
302            channel_len,
303            rebalance_state: Arc::new(Mutex::new(RebalanceState::new())),
304            rebalance_counter: Arc::new(AtomicU64::new(0)),
305            revoke_generation: Arc::new(AtomicU64::new(0)),
306            assign_generation: Arc::new(AtomicU64::new(0)),
307            last_seen_revoke_gen: 0,
308            schema_registry,
309            data_ready: Arc::new(Notify::new()),
310            msg_rx: None,
311            reader_handle: None,
312            reader_shutdown: None,
313            task_owner,
314            task_tracker,
315            blocking_tasks: KafkaBlockingTasks::new(blocking_guard),
316            reader_fault: Arc::new(Mutex::new(None)),
317            reader_drain_tx: None,
318            source_drain: None,
319            offset_snapshot: Arc::new(Mutex::new(OffsetTracker::new())),
320            deterministic_unrecorded_position: Arc::new(AtomicBool::new(false)),
321            source_name: Arc::from(""),
322            #[cfg(feature = "fpml")]
323            fpml: None,
324            vnode_assignment: None,
325            vnode_partition_routes: KafkaPartitionRoutes::new(),
326            manual_topic_partitions: std::collections::HashSet::new(),
327            manual_input_channels: Arc::from([]),
328            manual_partition_baselines: std::collections::HashMap::new(),
329            assignment_publication: Arc::new(Mutex::new(Arc::new(
330                KafkaAssignmentPublication::default(),
331            ))),
332            rotation_partition_baseline_count: Arc::new(AtomicUsize::new(0)),
333            applied_rotation_baseline_version: None,
334            batch_cursor_assignment_version: None,
335            reconciled_assignment_version: Arc::new(AtomicU64::new(0)),
336            last_avro_schema: None,
337            poll_payloads: Vec::new(),
338            poll_payload_buf: Vec::new(),
339            poll_payload_offsets: Vec::new(),
340            poll_row_metadata: Vec::new(),
341            poll_staged_offsets: Vec::new(),
342            poll_progress_offsets: Vec::new(),
343        }
344    }
345
346    /// Lifecycle state (Created → Initializing → Running → Closed).
347    #[must_use]
348    pub fn state(&self) -> ConnectorState {
349        self.state
350    }
351
352    /// Shared backpressure fill counter for downstream wiring.
353    #[must_use]
354    pub fn channel_len(&self) -> Arc<AtomicUsize> {
355        Arc::clone(&self.channel_len)
356    }
357
358    /// Shared partition assignment state (updated by rebalance callbacks).
359    #[must_use]
360    pub fn rebalance_state(&self) -> Arc<Mutex<RebalanceState>> {
361        Arc::clone(&self.rebalance_state)
362    }
363
364    /// Whether a Schema Registry client is configured.
365    #[must_use]
366    pub fn has_schema_registry(&self) -> bool {
367        self.schema_registry.is_some()
368    }
369
370    fn fail_startup(&mut self) {
371        self.state = ConnectorState::Failed;
372        self.progress = None;
373        self.blocking_tasks.retire();
374        self.blocking_tasks.ensure_reaper();
375        if let Some(consumer) = self.consumer.take() {
376            consumer.unsubscribe();
377            self.blocking_tasks
378                .spawn_final_drop(consumer, "consumer after failed startup");
379        }
380    }
381
382    fn capture_drain_positions(
383        &self,
384        inputs: &[KafkaDrainPartition],
385        prepare_deadline: Option<tokio::time::Instant>,
386    ) -> Result<Arc<[KafkaDrainPosition]>, ConnectorError> {
387        if prepare_deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
388            return Err(ConnectorError::Internal(
389                "Kafka drain deadline expired before cursor capture".into(),
390            ));
391        }
392        let publication = Arc::clone(&lock_or_recover(&self.assignment_publication));
393        let mut cut = Vec::with_capacity(inputs.len());
394        for input in inputs {
395            if prepare_deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
396                return Err(ConnectorError::Internal(
397                    "Kafka drain deadline expired during cursor capture".into(),
398                ));
399            }
400            let next_offset =
401                if let Some(offset) = self.offsets.get(input.topic.as_ref(), input.partition) {
402                    offset.checked_add(1).ok_or_else(|| {
403                        ConnectorError::Internal(format!(
404                            "Kafka drain offset overflow for '{}-{}'",
405                            input.topic, input.partition
406                        ))
407                    })?
408                } else if let Some(next) = rotation_partition_baseline(
409                    &publication.baselines,
410                    input.topic.as_ref(),
411                    input.partition,
412                ) {
413                    next
414                } else if let Some(next) = self
415                    .manual_partition_baselines
416                    .get(&(input.topic.to_string(), input.partition))
417                {
418                    *next
419                } else {
420                    return Err(ConnectorError::InvalidState {
421                        expected: format!(
422                            "numeric next-to-read position for drained Kafka input '{}-{}'",
423                            input.topic, input.partition
424                        ),
425                        actual: "no accepted offset or deterministic baseline".into(),
426                    });
427                };
428            cut.push(KafkaDrainPosition {
429                topic: Arc::clone(&input.topic),
430                partition: input.partition,
431                next_offset,
432            });
433        }
434        if prepare_deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
435            return Err(ConnectorError::Internal(
436                "Kafka drain deadline expired during cursor capture".into(),
437            ));
438        }
439        Ok(cut.into())
440    }
441
442    fn check_reader_health(&self, operation: &str) -> Result<(), ConnectorError> {
443        if let Some(reason) = lock_or_recover(&self.reader_fault).clone() {
444            return Err(ConnectorError::Internal(format!(
445                "Kafka reader terminated while {operation}: {reason}"
446            )));
447        }
448        if self
449            .reader_handle
450            .as_ref()
451            .is_some_and(tokio::task::JoinHandle::is_finished)
452        {
453            return Err(ConnectorError::Internal(format!(
454                "Kafka reader task exited while {operation}"
455            )));
456        }
457        Ok(())
458    }
459
460    fn validate_active_drain_cursor(&self) -> Result<(), ConnectorError> {
461        self.check_reader_health("validating a drain cursor")?;
462        let Some(active) = self.source_drain.as_ref() else {
463            return Ok(());
464        };
465        let Some(expected) = active.cut.as_deref() else {
466            return Ok(());
467        };
468        let Some(boundary) = active.boundary.as_ref() else {
469            return Err(ConnectorError::Internal(
470                "Kafka drain cut exists without its FIFO boundary".into(),
471            ));
472        };
473        // The certified cut remains valid while terminal materialization is pending; the
474        // preparation deadline no longer applies after the cut has been captured.
475        let current = self.capture_drain_positions(&boundary.inputs, None)?;
476        if current.as_ref() != expected {
477            return Err(ConnectorError::Internal(
478                "Kafka input cursor advanced after its drain receipt".into(),
479            ));
480        }
481        Ok(())
482    }
483}
484
485impl Drop for KafkaSource {
486    fn drop(&mut self) {
487        self.progress = None;
488        if let Some(shutdown) = self.reader_shutdown.take() {
489            let _ = shutdown.send(true);
490        }
491        if let Some(consumer) = self.consumer.as_ref() {
492            consumer.unsubscribe();
493        }
494        self.blocking_tasks.retire();
495        if let Some(handle) = self.reader_handle.take() {
496            ensure_background_task_reaper(handle, &self.task_owner, "reader");
497        }
498        self.blocking_tasks.ensure_reaper();
499        if let Some(consumer) = self.consumer.take() {
500            self.blocking_tasks.spawn_final_drop(consumer, "consumer");
501        }
502    }
503}
504
505impl std::fmt::Debug for KafkaSource {
506    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507        f.debug_struct("KafkaSource")
508            .field("state", &self.state)
509            .field("subscription", &self.config.subscription)
510            .field("group_id", &self.config.group_id)
511            .field("format", &self.config.format)
512            .field("delivery", &self.delivery)
513            .field("partitions", &self.offsets.partition_count())
514            .finish_non_exhaustive()
515    }
516}
517
518/// Stands in for the `FpML` decoder until the source starts it, so `FpML` messages never reach
519/// another deserializer.
520struct FpmlPending;
521
522impl RecordDeserializer for FpmlPending {
523    fn deserialize(&self, _: &[u8], _: &SchemaRef) -> Result<RecordBatch, SerdeError> {
524        Err(SerdeError::UnsupportedFormat(
525            "FpML messages decode through the FpML decoder, which starts with the source".into(),
526        ))
527    }
528
529    fn format(&self) -> Format {
530        Format::Fpml
531    }
532}
533
534fn select_deserializer(format: Format) -> Box<dyn RecordDeserializer> {
535    match format {
536        Format::Avro => Box::new(AvroDeserializer::new()),
537        Format::Fpml => Box::new(FpmlPending),
538        other => serde::create_deserializer(other).unwrap_or_else(|_| {
539            warn!(format = %other, "unsupported format, falling back to JSON");
540            Box::new(serde::json::JsonDeserializer::new())
541        }),
542    }
543}
544
545#[cfg(test)]
546mod tests;