Skip to main content

laminar_connectors/kafka/
source.rs

1//! Kafka source connector: consumes topics via rdkafka's `StreamConsumer`,
2//! deserializes with pluggable formats, and yields Arrow `RecordBatch`es.
3
4use arrow_schema::SchemaRef;
5use async_trait::async_trait;
6use laminar_core::checkpoint::CommittedSourceHandoff;
7use rdkafka::consumer::{CommitMode, Consumer, StreamConsumer};
8use rdkafka::error::KafkaError;
9use rdkafka::message::Message;
10use rdkafka::ClientConfig;
11use rdkafka::TopicPartitionList;
12use std::collections::BTreeSet;
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;
20use crate::config::{ConnectorConfig, ConnectorState};
21use crate::connector::{
22    ConnectorTaskGuard, ConnectorTaskOwner, ConnectorTaskTracker, DeliveryGuarantee, SourceBatch,
23    SourceConnector, SourceConsistency, SourceContract, SourceDrainOutcome, SourceDrainRequest,
24    SourceDrainResolution, SourcePosition, SourceStart, SourceTopology,
25};
26use crate::error::{ConnectorError, SerdeError};
27use crate::serde::{self, Format, RecordDeserializer};
28
29use super::avro::AvroDeserializer;
30use super::config::{
31    resolve_value_subject, KafkaSourceConfig, OffsetReset, SchemaEvolutionStrategy, StartupMode,
32    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
44/// Locks a mutex, recovering from poison if a prior holder panicked.
45///
46/// Used for state shared with rdkafka's rebalance callback thread.
47/// Poison indicates a panic in the callback — the data may be stale
48/// but is structurally sound, so we recover rather than propagate.
49fn lock_or_recover<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
50    mutex.lock().unwrap_or_else(|poisoned| {
51        tracing::warn!("mutex poisoned, recovering");
52        poisoned.into_inner()
53    })
54}
55
56fn publish_reader_fault(
57    fault: &Mutex<Option<Arc<str>>>,
58    data_ready: &Notify,
59    reason: impl Into<Arc<str>>,
60) {
61    let mut published = lock_or_recover(fault);
62    if published.is_none() {
63        *published = Some(reason.into());
64    }
65    drop(published);
66    data_ready.notify_one();
67}
68
69/// A guaranteed-delivery poll cannot retry in place after removing input from the reader queue.
70/// The Kafka consumer has already moved past that input, so only a fresh generation seeking the
71/// durable checkpoint can retry it safely. Preserve terminal errors, but turn transient errors into
72/// terminal-for-this-generation failures so the source actor cannot consume later records.
73fn terminalize_guaranteed_poll_error(
74    delivery: DeliveryGuarantee,
75    state: &mut ConnectorState,
76    metrics: &KafkaSourceMetrics,
77    reader_shutdown: Option<&tokio::sync::watch::Sender<bool>>,
78    error: ConnectorError,
79) -> ConnectorError {
80    if delivery == DeliveryGuarantee::BestEffort {
81        return error;
82    }
83
84    metrics.record_error();
85    *state = ConnectorState::Failed;
86    if let Some(shutdown) = reader_shutdown {
87        let _ = shutdown.send(true);
88    }
89    warn!(delivery = %delivery, %error, "Kafka source generation stopped after a post-drain failure");
90
91    if error.is_transient() {
92        ConnectorError::Internal(format!(
93            "Kafka guaranteed-delivery poll failed after draining input; recovery from the durable cursor is required: {error}"
94        ))
95    } else {
96        error
97    }
98}
99
100/// Payload sent from the background Kafka reader task to [`KafkaSource::poll_batch`].
101struct KafkaPayload {
102    data: Vec<u8>,
103    topic: Arc<str>,
104    partition: i32,
105    /// Precomputed route for cluster-owned inputs; absent for local readers.
106    partition_vnode: Option<u32>,
107    offset: i64,
108    timestamp_ms: Option<i64>,
109    /// Message headers as a JSON string; populated only when `include_headers` is set.
110    headers_json: Option<String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114struct KafkaDrainPartition {
115    topic: Arc<str>,
116    partition: i32,
117}
118
119#[derive(Debug, Clone)]
120struct KafkaDrainBoundary {
121    round: laminar_core::checkpoint::AssignmentDrainId,
122    inputs: Arc<[KafkaDrainPartition]>,
123}
124
125enum KafkaReaderItem {
126    Payload(KafkaPayload),
127    DrainBoundary(KafkaDrainBoundary),
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131struct KafkaDrainPosition {
132    topic: Arc<str>,
133    partition: i32,
134    next_offset: i64,
135}
136
137enum KafkaReaderDrainCommand {
138    Begin {
139        request: SourceDrainRequest,
140        deadline: tokio::time::Instant,
141    },
142    Resolve {
143        resolution: SourceDrainResolution,
144        cut: Arc<[KafkaDrainPosition]>,
145        deadline: tokio::time::Instant,
146        execution: Arc<AtomicU8>,
147        reply: tokio::sync::oneshot::Sender<Result<(), String>>,
148    },
149}
150
151struct KafkaReaderDrain {
152    request: SourceDrainRequest,
153    prepare_deadline: tokio::time::Instant,
154    inputs: Arc<[KafkaDrainPartition]>,
155    held_inputs: Arc<[KafkaDrainPartition]>,
156    held_assignment_version: Option<u64>,
157    hold_complete: bool,
158    boundary_queued: bool,
159}
160
161struct KafkaPendingDrainResolution {
162    resolution: SourceDrainResolution,
163    deadline: tokio::time::Instant,
164    execution: Arc<AtomicU8>,
165    reply: tokio::sync::oneshot::Receiver<Result<(), String>>,
166    terminal_error: Option<Arc<str>>,
167}
168
169struct KafkaSourceDrain {
170    request: SourceDrainRequest,
171    prepare_deadline: tokio::time::Instant,
172    boundary: Option<KafkaDrainBoundary>,
173    cut: Option<Arc<[KafkaDrainPosition]>>,
174    pending_resolution: Option<KafkaPendingDrainResolution>,
175}
176
177const KAFKA_BACKGROUND_CLOSE_BUDGET: std::time::Duration = std::time::Duration::from_millis(500);
178const KAFKA_DRAIN_EXECUTION_PENDING: u8 = 0;
179const KAFKA_DRAIN_EXECUTION_STARTED: u8 = 1;
180const KAFKA_DRAIN_EXECUTION_CANCELLED: u8 = 2;
181const KAFKA_PARTITION_INVENTORY_METADATA: &str = "kafka.partition.inventory.v1";
182const KAFKA_POSITION_LOOKUP_BUDGET: std::time::Duration = std::time::Duration::from_secs(10);
183const KAFKA_POSITION_LOOKUP_CONCURRENCY: usize = 32;
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186enum KafkaBlockingTaskError {
187    Retired,
188    WorkerDropped,
189}
190
191impl std::fmt::Display for KafkaBlockingTaskError {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        match self {
194            Self::Retired => f.write_str("Kafka connector generation retired"),
195            Self::WorkerDropped => f.write_str("Kafka blocking worker ended without a result"),
196        }
197    }
198}
199
200/// Retains every synchronous librdkafka call started by one source generation.
201///
202/// Tokio cannot cancel a `spawn_blocking` closure after it starts. Keeping its
203/// handle here lets close abort queued work and reap completed joins. The
204/// connector's generic terminal tracker, retained by `terminal_guard`, is the
205/// replacement fence when a native call outlives its calling future.
206#[derive(Clone)]
207struct KafkaBlockingTasks {
208    retired: Arc<AtomicBool>,
209    handles: Arc<AsyncMutex<Vec<tokio::task::JoinHandle<()>>>>,
210    reaper_started: Arc<AtomicBool>,
211    terminal_guard: Arc<ConnectorTaskGuard>,
212}
213
214impl KafkaBlockingTasks {
215    fn new(terminal_guard: ConnectorTaskGuard) -> Self {
216        Self {
217            retired: Arc::new(AtomicBool::new(false)),
218            handles: Arc::new(AsyncMutex::new(Vec::new())),
219            reaper_started: Arc::new(AtomicBool::new(false)),
220            terminal_guard: Arc::new(terminal_guard),
221        }
222    }
223
224    async fn run<T, F>(&self, operation: F) -> Result<T, KafkaBlockingTaskError>
225    where
226        T: Send + 'static,
227        F: FnOnce() -> T + Send + 'static,
228    {
229        if self.retired.load(Ordering::Acquire) {
230            return Err(KafkaBlockingTaskError::Retired);
231        }
232
233        let (result_tx, result_rx) = tokio::sync::oneshot::channel();
234        let mut handles = self.handles.lock().await;
235        if self.retired.load(Ordering::Acquire) {
236            return Err(KafkaBlockingTaskError::Retired);
237        }
238        // This under-lock check is the admission seal: after retirement no
239        // code path may clone the grouped terminal guard for provider work.
240        let retired = Arc::clone(&self.retired);
241        let terminal_guard = Arc::clone(&self.terminal_guard);
242        handles.push(tokio::task::spawn_blocking(move || {
243            let _terminal_guard = terminal_guard;
244            if retired.load(Ordering::Acquire) {
245                let _ = result_tx.send(Err(KafkaBlockingTaskError::Retired));
246                return;
247            }
248            let result = operation();
249            if retired.load(Ordering::Acquire) {
250                let _ = result_tx.send(Err(KafkaBlockingTaskError::Retired));
251            } else {
252                let _ = result_tx.send(Ok(result));
253            }
254        }));
255        drop(handles);
256
257        let result = result_rx
258            .await
259            .map_err(|_| KafkaBlockingTaskError::WorkerDropped)?;
260        self.reap_finished().await;
261        result
262    }
263
264    async fn reap_finished(&self) {
265        let completed = {
266            let mut handles = self.handles.lock().await;
267            let mut completed = Vec::new();
268            let mut index = 0;
269            while index < handles.len() {
270                if handles[index].is_finished() {
271                    completed.push(handles.swap_remove(index));
272                } else {
273                    index += 1;
274                }
275            }
276            completed
277        };
278        for handle in completed {
279            if let Err(error) = handle.await {
280                warn!(%error, "Kafka blocking worker failed");
281            }
282        }
283    }
284
285    fn retire(&self) {
286        self.retired.store(true, Ordering::Release);
287    }
288
289    async fn join_until(&self, deadline: tokio::time::Instant) -> bool {
290        self.retire();
291        let mut handles = self.handles.lock().await;
292        for handle in handles.iter() {
293            handle.abort();
294        }
295        while let Some(handle) = handles.first_mut() {
296            match tokio::time::timeout_at(deadline, handle).await {
297                Ok(result) => {
298                    if let Err(error) = result {
299                        debug!(%error, "Kafka blocking worker cancelled during retirement");
300                    }
301                    handles.swap_remove(0);
302                }
303                Err(_) => return false,
304            }
305        }
306        self.reaper_started.store(true, Ordering::Release);
307        true
308    }
309
310    fn ensure_reaper(&self) {
311        self.retire();
312        if self.reaper_started.swap(true, Ordering::AcqRel) {
313            return;
314        }
315        // Retirement is published before this lock. If the list is empty while
316        // holding it, no racing `run` can pass its under-lock retirement check
317        // and install a later handle.
318        if self
319            .handles
320            .try_lock()
321            .is_ok_and(|handles| handles.is_empty())
322        {
323            return;
324        }
325        let generation = self.clone();
326        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
327            self.reaper_started.store(false, Ordering::Release);
328            warn!("Kafka blocking generation retired outside a Tokio runtime");
329            return;
330        };
331        drop(runtime.spawn(async move {
332            let mut handles = generation.handles.lock().await;
333            for handle in handles.iter() {
334                handle.abort();
335            }
336            while let Some(handle) = handles.first_mut() {
337                if let Err(error) = handle.await {
338                    debug!(%error, "Kafka blocking worker cancelled during reaping");
339                }
340                handles.swap_remove(0);
341            }
342        }));
343    }
344
345    /// Admit only terminal destruction after retirement. This does not run a
346    /// provider operation or expose a result to the retired connector.
347    fn spawn_final_drop<T: Send + Sync + 'static>(&self, owner: Arc<T>, resource: &'static str) {
348        let terminal_guard = Arc::clone(&self.terminal_guard);
349        let reap = move || {
350            let _terminal_guard = terminal_guard;
351            while Arc::strong_count(&owner) > 1 {
352                std::thread::sleep(std::time::Duration::from_millis(1));
353            }
354            drop(owner);
355        };
356        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
357            drop(runtime.spawn_blocking(reap));
358        } else {
359            warn!(resource, "Kafka resource retired outside a Tokio runtime");
360            if let Err(error) = std::thread::Builder::new()
361                .name("laminardb-kafka-resource-drop".into())
362                .spawn(reap)
363            {
364                tracing::error!(resource, %error, "failed to start Kafka resource teardown thread");
365            }
366        }
367    }
368
369    #[cfg(test)]
370    async fn tracked_count(&self) -> usize {
371        self.handles.lock().await.len()
372    }
373}
374
375struct KafkaDrainWaitGuard {
376    execution: Arc<AtomicU8>,
377    armed: bool,
378}
379
380impl KafkaDrainWaitGuard {
381    fn new(execution: Arc<AtomicU8>) -> Self {
382        Self {
383            execution,
384            armed: true,
385        }
386    }
387
388    fn disarm(&mut self) {
389        self.armed = false;
390    }
391}
392
393impl Drop for KafkaDrainWaitGuard {
394    fn drop(&mut self) {
395        if self.armed {
396            let _ = self.execution.compare_exchange(
397                KAFKA_DRAIN_EXECUTION_PENDING,
398                KAFKA_DRAIN_EXECUTION_CANCELLED,
399                Ordering::AcqRel,
400                Ordering::Acquire,
401            );
402        }
403    }
404}
405
406fn claim_kafka_drain_execution(
407    execution: &AtomicU8,
408    deadline: tokio::time::Instant,
409) -> Result<(), String> {
410    if tokio::time::Instant::now() >= deadline {
411        return Err("Kafka drain deadline expired before provider execution".into());
412    }
413    execution
414        .compare_exchange(
415            KAFKA_DRAIN_EXECUTION_PENDING,
416            KAFKA_DRAIN_EXECUTION_STARTED,
417            Ordering::AcqRel,
418            Ordering::Acquire,
419        )
420        .map(|_| ())
421        .map_err(|state| match state {
422            KAFKA_DRAIN_EXECUTION_CANCELLED => {
423                "Kafka drain resolution was cancelled before execution".into()
424            }
425            _ => "Kafka drain resolution execution was already claimed".into(),
426        })
427}
428
429type KafkaPartitionSet = std::collections::HashSet<(String, i32)>;
430type KafkaPartitionBaselines = std::collections::HashMap<(String, i32), i64>;
431
432struct KafkaStartPlan {
433    config: KafkaSourceConfig,
434    delivery: DeliveryGuarantee,
435    is_resume: bool,
436    resume_inventory: Option<KafkaPartitionSet>,
437    resume_baselines: KafkaPartitionBaselines,
438}
439type KafkaPartitionRoutes = std::collections::HashMap<Arc<str>, Arc<[u32]>>;
440type KafkaRotationBaselines =
441    std::collections::HashMap<Arc<str>, std::collections::HashMap<i32, i64>>;
442
443fn kafka_drain_partitions(
444    assignment: &TopicPartitionList,
445) -> Result<Arc<[KafkaDrainPartition]>, ConnectorError> {
446    let mut inputs = Vec::with_capacity(assignment.count());
447    for element in assignment.elements() {
448        if element.topic().is_empty() {
449            return Err(ConnectorError::ConfigurationError(
450                "Kafka assignment contains an empty topic".into(),
451            ));
452        }
453        if element.partition() < 0 {
454            return Err(ConnectorError::ConfigurationError(format!(
455                "Kafka assignment contains invalid partition '{}-{}'",
456                element.topic(),
457                element.partition()
458            )));
459        }
460        inputs.push(KafkaDrainPartition {
461            topic: Arc::from(element.topic()),
462            partition: element.partition(),
463        });
464    }
465    inputs.sort_unstable_by(|left, right| {
466        left.topic
467            .as_ref()
468            .cmp(right.topic.as_ref())
469            .then(left.partition.cmp(&right.partition))
470    });
471    if let Some(duplicate) = inputs.windows(2).find(|pair| pair[0] == pair[1]) {
472        return Err(ConnectorError::ConfigurationError(format!(
473            "Kafka assignment contains duplicate input '{}-{}'",
474            duplicate[0].topic, duplicate[0].partition
475        )));
476    }
477    Ok(inputs.into())
478}
479
480fn kafka_partition_routes(
481    source_identity: &str,
482    vnode_count: u32,
483    topic_meta: &[(Arc<str>, i32)],
484) -> Result<KafkaPartitionRoutes, ConnectorError> {
485    let mut routes = KafkaPartitionRoutes::with_capacity(topic_meta.len());
486    for (topic, count) in topic_meta {
487        if *count < 0 {
488            return Err(ConnectorError::ConfigurationError(format!(
489                "Kafka topic '{topic}' reported a negative partition count {count}"
490            )));
491        }
492        let topic_routes =
493            super::vnode_routing::partition_vnodes(source_identity, topic, *count, vnode_count)?;
494        if routes
495            .insert(Arc::clone(topic), topic_routes.into())
496            .is_some()
497        {
498            return Err(ConnectorError::ConfigurationError(format!(
499                "Kafka vnode assignment contains duplicate topic '{topic}'"
500            )));
501        }
502    }
503    Ok(routes)
504}
505
506fn kafka_partition_set(tpl: &TopicPartitionList) -> Result<KafkaPartitionSet, String> {
507    let mut partitions = KafkaPartitionSet::with_capacity(tpl.count());
508    for element in tpl.elements() {
509        if element.partition() < 0 {
510            return Err(format!(
511                "Kafka assignment contains negative partition '{}-{}'",
512                element.topic(),
513                element.partition()
514            ));
515        }
516        let partition = (element.topic().to_string(), element.partition());
517        if !partitions.insert(partition) {
518            return Err(format!(
519                "Kafka assignment contains duplicate partition '{}-{}'",
520                element.topic(),
521                element.partition()
522            ));
523        }
524    }
525    Ok(partitions)
526}
527
528fn validate_kafka_assignment(
529    expected: &KafkaPartitionSet,
530    actual: &KafkaPartitionSet,
531) -> Result<(), String> {
532    if expected == actual {
533        return Ok(());
534    }
535    let missing = expected
536        .difference(actual)
537        .min()
538        .map(|(topic, partition)| format!("{topic}-{partition}"));
539    let unexpected = actual
540        .difference(expected)
541        .min()
542        .map(|(topic, partition)| format!("{topic}-{partition}"));
543    Err(format!(
544        "Kafka consumer assignment mismatch: expected {} partitions, found {}; first missing: {}, first unexpected: {}",
545        expected.len(),
546        actual.len(),
547        missing.as_deref().unwrap_or("none"),
548        unexpected.as_deref().unwrap_or("none")
549    ))
550}
551
552fn kafka_bootstrap_is_unassigned(
553    published: &laminar_core::state::VnodeAssignmentSnapshot,
554    self_id: laminar_core::state::NodeId,
555) -> Result<bool, ConnectorError> {
556    if self_id.is_unassigned() {
557        return Err(ConnectorError::ConfigurationError(
558            "Kafka vnode ownership requires a nonzero node identity".into(),
559        ));
560    }
561    if published.owners().is_empty() {
562        return Err(ConnectorError::ConfigurationError(
563            "Kafka vnode assignment cannot use an empty owner map".into(),
564        ));
565    }
566    if published.version() != 0 {
567        super::vnode_routing::validate_owner_map(published.owners(), self_id)?;
568        return Ok(false);
569    }
570    if published.has_committed_handoff()
571        || !published
572            .owners()
573            .iter()
574            .all(laminar_core::state::NodeId::is_unassigned)
575    {
576        return Err(ConnectorError::ConfigurationError(
577            "Kafka vnode assignment version 0 must be the fully unassigned bootstrap publication"
578                .into(),
579        ));
580    }
581    Ok(true)
582}
583
584fn kafka_owned_partition_sets(
585    routes: &KafkaPartitionRoutes,
586    published: &laminar_core::state::VnodeAssignmentSnapshot,
587    self_id: laminar_core::state::NodeId,
588    reconciled_version: u64,
589) -> Result<(KafkaPartitionSet, KafkaPartitionSet), ConnectorError> {
590    super::vnode_routing::validate_owner_map(published.owners(), self_id)?;
591    let mut owned = KafkaPartitionSet::new();
592    let mut reacquired = KafkaPartitionSet::new();
593    for (topic, topic_routes) in routes {
594        for (partition_index, vnode) in topic_routes.iter().copied().enumerate() {
595            let vnode_index = usize::try_from(vnode).map_err(|_| {
596                ConnectorError::ConfigurationError(
597                    "Kafka vnode id cannot be represented on this platform".into(),
598                )
599            })?;
600            let assigned_owner = published.owners().get(vnode_index).ok_or_else(|| {
601                ConnectorError::ConfigurationError(format!(
602                    "Kafka cached vnode {vnode} is outside owner map cardinality {}",
603                    published.owners().len()
604                ))
605            })?;
606            if *assigned_owner != self_id {
607                continue;
608            }
609            let partition = i32::try_from(partition_index).map_err(|_| {
610                ConnectorError::ConfigurationError(format!(
611                    "Kafka topic '{topic}' partition index cannot be represented as i32"
612                ))
613            })?;
614            let input = (topic.to_string(), partition);
615            if reconciled_version != 0
616                && published
617                    .owner_changed_version(vnode)
618                    .is_some_and(|changed| changed > reconciled_version)
619            {
620                reacquired.insert(input.clone());
621            }
622            owned.insert(input);
623        }
624    }
625    Ok((owned, reacquired))
626}
627
628fn kafka_assignment_fence_matches(
629    registry: &laminar_core::state::VnodeRegistry,
630    reconciled: &AtomicU64,
631    assignment_version: u64,
632) -> bool {
633    assignment_version != 0
634        && registry.assignment_version() == assignment_version
635        && reconciled.load(Ordering::Acquire) == assignment_version
636}
637
638fn try_capture_at_assignment_fence<T>(
639    registry: &laminar_core::state::VnodeRegistry,
640    reconciled: &AtomicU64,
641    assignment_publication: &Mutex<Arc<KafkaAssignmentPublication>>,
642    capture: impl FnOnce(&KafkaAssignmentPublication) -> Result<T, ConnectorError>,
643) -> Result<Option<T>, ConnectorError> {
644    // Keep this lock order: registry publication, then Kafka publication.
645    let publication = {
646        let published = registry.read_assignment();
647        let version = published.version();
648        if version == 0 || reconciled.load(Ordering::Acquire) != version {
649            return Ok(None);
650        }
651        let publication = Arc::clone(&lock_or_recover(assignment_publication));
652        if publication.assignment_version != version {
653            return Ok(None);
654        }
655        publication
656    };
657    let captured = capture(&publication)?;
658    Ok(
659        kafka_assignment_fence_matches(registry, reconciled, publication.assignment_version)
660            .then_some(captured),
661    )
662}
663
664fn cached_partition_vnode(
665    topic_routes: Option<&[u32]>,
666    partition: i32,
667) -> Result<Option<u32>, ConnectorError> {
668    let Some(topic_routes) = topic_routes else {
669        return Ok(None);
670    };
671    let partition_index = usize::try_from(partition).map_err(|_| {
672        ConnectorError::ConfigurationError(format!(
673            "Kafka payload has negative partition id {partition}"
674        ))
675    })?;
676    topic_routes
677        .get(partition_index)
678        .copied()
679        .map(Some)
680        .ok_or_else(|| {
681            ConnectorError::ConfigurationError(format!(
682                "Kafka payload partition {partition} is outside the activated topic inventory {}",
683                topic_routes.len()
684            ))
685        })
686}
687
688fn kafka_partition_result_errors(tpl: &TopicPartitionList) -> Vec<String> {
689    tpl.elements()
690        .iter()
691        .filter_map(|element| {
692            element
693                .error()
694                .err()
695                .map(|error| format!("{}-{}: {error}", element.topic(), element.partition()))
696        })
697        .collect()
698}
699
700fn validate_kafka_partition_results(
701    operation: &str,
702    tpl: &TopicPartitionList,
703) -> Result<(), String> {
704    let errors = kafka_partition_result_errors(tpl);
705    validate_kafka_partition_error_list(operation, &errors)
706}
707
708fn validate_kafka_partition_error_list(operation: &str, errors: &[String]) -> Result<(), String> {
709    if errors.is_empty() {
710        Ok(())
711    } else {
712        Err(format!(
713            "Kafka {operation} failed for partitions: {}",
714            errors.join(", ")
715        ))
716    }
717}
718
719fn kafka_drain_target_ready(
720    target_version: u64,
721    registry_version: u64,
722    reconciled_version: u64,
723) -> Result<bool, String> {
724    if registry_version > target_version || reconciled_version > target_version {
725        return Err(format!(
726            "Kafka drain target {target_version} was superseded (registry {registry_version}, reconciled {reconciled_version})"
727        ));
728    }
729    if reconciled_version > registry_version {
730        return Err(format!(
731            "Kafka drain reconciliation {reconciled_version} is ahead of registry {registry_version}"
732        ));
733    }
734    Ok(registry_version == target_version && reconciled_version == target_version)
735}
736
737async fn resolve_kafka_reader_drain(
738    consumer: &Arc<StreamConsumer<LaminarConsumerContext>>,
739    blocking_tasks: &KafkaBlockingTasks,
740    vnode_reassign: Option<&(
741        Arc<laminar_core::state::VnodeRegistry>,
742        laminar_core::state::NodeId,
743    )>,
744    active: &KafkaReaderDrain,
745    resolution: SourceDrainResolution,
746    cut: &[KafkaDrainPosition],
747    globally_paused: bool,
748    deadline: tokio::time::Instant,
749    execution: &Arc<AtomicU8>,
750) -> Result<(), String> {
751    if resolution.round != active.request.round {
752        return Err("Kafka drain resolution does not match the active round".into());
753    }
754    let Some((registry, _)) = vnode_reassign else {
755        return Err("Kafka drain resolution has no cluster assignment".into());
756    };
757    if registry.assignment_version() != resolution.round.target_version {
758        return Err(format!(
759            "Kafka drain target {} is not installed (current {})",
760            resolution.round.target_version,
761            registry.assignment_version()
762        ));
763    }
764    let assignment = consumer
765        .assignment()
766        .map_err(|error| format!("Kafka drain could not inspect target assignment: {error}"))?;
767    if resolution.outcome == SourceDrainOutcome::Abort {
768        let assigned = kafka_partition_set(&assignment)
769            .map_err(|error| format!("Kafka drain target assignment is invalid: {error}"))?;
770        let mut seek = TopicPartitionList::new();
771        for position in cut
772            .iter()
773            .filter(|position| assigned.contains(&(position.topic.to_string(), position.partition)))
774        {
775            seek.add_partition_offset(
776                position.topic.as_ref(),
777                position.partition,
778                rdkafka::Offset::Offset(position.next_offset),
779            )
780            .map_err(|error| {
781                format!(
782                    "Kafka drain could not build abort seek for '{}-{}': {error}",
783                    position.topic, position.partition
784                )
785            })?;
786        }
787        if seek.count() > 0 {
788            let seek_consumer = Arc::clone(consumer);
789            let seek_execution = Arc::clone(execution);
790            let positioned = tokio::time::timeout_at(
791                deadline,
792                blocking_tasks.run(move || -> Result<_, String> {
793                    claim_kafka_drain_execution(&seek_execution, deadline)?;
794                    let timeout = deadline
795                        .saturating_duration_since(tokio::time::Instant::now())
796                        .min(std::time::Duration::from_secs(5));
797                    if timeout.is_zero() {
798                        return Err("Kafka drain deadline expired before abort seek".into());
799                    }
800                    seek_consumer
801                        .seek_partitions(seek, timeout)
802                        .map_err(|error| format!("Kafka drain abort seek failed: {error}"))
803                }),
804            )
805            .await
806            .map_err(|_| "Kafka drain deadline expired during abort seek".to_string())?
807            .map_err(|error| format!("Kafka drain abort seek task failed: {error}"))??;
808            validate_kafka_partition_results("drain abort seek", &positioned)?;
809        } else {
810            claim_kafka_drain_execution(execution, deadline)?;
811        }
812    } else {
813        claim_kafka_drain_execution(execution, deadline)?;
814    }
815    if tokio::time::Instant::now() >= deadline {
816        return Err("Kafka drain deadline expired before target resume".into());
817    }
818    if !globally_paused && assignment.count() > 0 {
819        consumer
820            .resume(&assignment)
821            .map_err(|error| format!("Kafka drain target resume failed: {error}"))?;
822        validate_kafka_partition_results("drain target resume", &assignment)?;
823    }
824    Ok(())
825}
826
827fn decode_committed_kafka_handoff(
828    handoff: &CommittedSourceHandoff,
829    source: &str,
830) -> Result<(OffsetTracker, KafkaPartitionBaselines), ConnectorError> {
831    let source_state = handoff.source(source).ok_or_else(|| {
832        ConnectorError::ConfigurationError(format!(
833            "committed checkpoint {:?} has no handoff state for Kafka source '{source}'",
834            handoff.attempt()
835        ))
836    })?;
837    let checkpoint = source_state.checkpoint();
838    let assignment_version = checkpoint.source_assignment_version.ok_or_else(|| {
839        ConnectorError::ConfigurationError(format!(
840            "Kafka source '{source}' handoff is missing its source assignment version"
841        ))
842    })?;
843    let checkpoint_assignment_version = NonZeroU64::new(handoff.checkpoint_assignment_version())
844        .ok_or_else(|| {
845            ConnectorError::ConfigurationError(format!(
846                "Kafka source '{source}' handoff has a zero checkpoint assignment version"
847            ))
848        })?;
849    if assignment_version != checkpoint_assignment_version {
850        return Err(ConnectorError::ConfigurationError(format!(
851            "Kafka source '{source}' handoff assignment version {assignment_version} does not match checkpoint fence {checkpoint_assignment_version}",
852        )));
853    }
854
855    let offsets = OffsetTracker::try_from_connector_checkpoint(checkpoint)?;
856    let baselines = decode_partition_baselines_from_offsets(&checkpoint.offsets)?;
857    Ok((offsets, baselines))
858}
859
860#[derive(Clone, Default)]
861struct KafkaAssignmentPublication {
862    assignment_version: u64,
863    owned_partitions: Arc<KafkaPartitionSet>,
864    baselines: KafkaRotationBaselines,
865}
866
867impl KafkaAssignmentPublication {
868    fn new(
869        assignment_version: u64,
870        owned_partitions: Arc<KafkaPartitionSet>,
871        baselines: KafkaRotationBaselines,
872    ) -> Self {
873        Self {
874            assignment_version,
875            owned_partitions,
876            baselines,
877        }
878    }
879}
880
881async fn join_background_task(
882    handle: &mut Option<tokio::task::JoinHandle<()>>,
883    deadline: tokio::time::Instant,
884    task: &'static str,
885) {
886    let Some(owned) = handle.as_mut() else {
887        return;
888    };
889    let completed = match tokio::time::timeout_at(deadline, &mut *owned).await {
890        Ok(Ok(())) => true,
891        Ok(Err(error)) => {
892            warn!(task, %error, "Kafka background task failed during shutdown");
893            true
894        }
895        Err(_) => {
896            warn!(
897                task,
898                "Kafka background task shutdown timed out; aborting it"
899            );
900            owned.abort();
901            false
902        }
903    };
904    if completed {
905        *handle = None;
906    }
907}
908
909fn ensure_background_task_reaper(
910    handle: tokio::task::JoinHandle<()>,
911    task_owner: &ConnectorTaskOwner,
912    task: &'static str,
913) {
914    handle.abort();
915    let Ok(runtime) = tokio::runtime::Handle::try_current() else {
916        warn!(
917            task,
918            "Kafka background task retired outside a Tokio runtime"
919        );
920        return;
921    };
922    let Some(terminal_guard) = task_owner.track() else {
923        warn!(
924            task,
925            "Kafka task generation was sealed before reader reaping"
926        );
927        return;
928    };
929    drop(runtime.spawn(async move {
930        let _terminal_guard = terminal_guard;
931        if let Err(error) = handle.await {
932            debug!(task, %error, "Kafka retired background task reaped");
933        }
934    }));
935}
936
937/// Keep one owner on Tokio's blocking pool until all async task owners drain, then perform the
938/// potentially blocking final drop there. The generation tracker retains the blocking handle if
939/// the caller's close deadline expires.
940async fn reap_last_arc_off_runtime<T: Send + Sync + 'static>(
941    blocking_tasks: &KafkaBlockingTasks,
942    owner: Arc<T>,
943    deadline: tokio::time::Instant,
944    resource: &'static str,
945) {
946    let reaper = blocking_tasks.run(move || {
947        while Arc::strong_count(&owner) > 1 {
948            std::thread::sleep(std::time::Duration::from_millis(1));
949        }
950        drop(owner);
951    });
952    match tokio::time::timeout_at(deadline, reaper).await {
953        Ok(Ok(())) => {}
954        Ok(Err(error)) => warn!(resource, %error, "Kafka blocking reaper failed"),
955        Err(_) => warn!(
956            resource,
957            "Kafka resource cleanup exceeded close deadline; generation reaper retained it"
958        ),
959    }
960}
961
962/// Single-consumer async receiver for the reader → `poll_batch` queue.
963type KafkaReaderRx = crossfire::AsyncRx<crossfire::mpsc::Array<KafkaReaderItem>>;
964
965/// Kafka source connector that consumes messages and produces Arrow batches.
966///
967/// Operates in Ring 1 (background) and pushes deserialized `RecordBatch`
968/// data to Ring 0 via the streaming `Source<T>` API.
969///
970/// # Lifecycle
971///
972/// 1. Create with [`KafkaSource::new`] or [`KafkaSource::with_schema_registry`]
973/// 2. Call `start()` with the initial or exact recovered position
974/// 3. Call `poll_batch()` in a loop to consume messages
975/// 4. Call `checkpoint()` for fault tolerance
976/// 5. Call `close()` for clean shutdown
977///
978/// Guaranteed delivery uses engine-owned manual assignment: the complete explicit topic inventory
979/// in embedded/single-node mode or the owned vnode subset in cluster mode. Broker consumer-group
980/// ownership can revoke a partition before an asynchronous engine checkpoint captures its final
981/// offset, so dynamic group ownership is intentionally limited to `BestEffort`.
982pub struct KafkaSource {
983    consumer: Option<Arc<StreamConsumer<LaminarConsumerContext>>>,
984    config: KafkaSourceConfig,
985    /// Delivery contract selected by the engine for this connector generation.
986    /// Only `BestEffort` may discard records that fail deserialization.
987    delivery: DeliveryGuarantee,
988    deserializer: Box<dyn RecordDeserializer>,
989    offsets: OffsetTracker,
990    state: ConnectorState,
991    metrics: KafkaSourceMetrics,
992    schema: SchemaRef,
993    channel_len: Arc<AtomicUsize>,
994    rebalance_state: Arc<Mutex<RebalanceState>>,
995    /// Shared rebalance counter bridging `LaminarConsumerContext` → `KafkaSourceMetrics`.
996    rebalance_counter: Arc<AtomicU64>,
997    /// Bumped on each partition revoke; `poll_batch` compares it lock-free to
998    /// detect a revoke and purge the lost partitions' offsets.
999    revoke_generation: Arc<AtomicU64>,
1000    /// Bumped on each partition assign; the reader loop seeks the newly-assigned
1001    /// partitions on change (see the seek block in `ensure_reader_started`).
1002    assign_generation: Arc<AtomicU64>,
1003    last_seen_revoke_gen: u64,
1004    schema_registry: Option<Arc<SchemaRegistryClient>>,
1005    data_ready: Arc<Notify>,
1006    msg_rx: Option<KafkaReaderRx>,
1007    reader_handle: Option<tokio::task::JoinHandle<()>>,
1008    reader_shutdown: Option<tokio::sync::watch::Sender<bool>>,
1009    /// Sole admission authority for detached work in this connector generation.
1010    task_owner: ConnectorTaskOwner,
1011    /// Cloneable terminal observer returned to the connector runtime.
1012    task_tracker: ConnectorTaskTracker,
1013    /// Blocking native calls owned and fenced by this connector generation.
1014    blocking_tasks: KafkaBlockingTasks,
1015    /// First terminal reader reason, published before an assignment wait can hide its exit.
1016    reader_fault: Arc<Mutex<Option<Arc<str>>>>,
1017    /// Allocated only for a cluster-assigned instance; local readers have no rotation path.
1018    reader_drain_tx: Option<tokio::sync::mpsc::UnboundedSender<KafkaReaderDrainCommand>>,
1019    source_drain: Option<KafkaSourceDrain>,
1020    /// Offset snapshot for the rebalance callback's seek-on-assign, refreshed
1021    /// once per `poll_batch()` cycle.
1022    offset_snapshot: Arc<Mutex<OffsetTracker>>,
1023    /// When set, every partition absent from the installed engine checkpoint is
1024    /// explicitly positioned at the configured deterministic start. This must
1025    /// remain armed for the connector lifetime: a later rebalance can introduce
1026    /// a partition that was not present in the startup checkpoint, and using the
1027    /// broker's stored group offset there would cross engine timelines.
1028    deterministic_unrecorded_position: Arc<AtomicBool>,
1029
1030    /// Stable catalog source identity for namespaced cluster handoff offsets.
1031    source_name: Arc<str>,
1032
1033    /// Cluster vnode assignment: when set, `start()` manually `assign()`s this
1034    /// source's vnode-owned partitions instead of `subscribe()`, and the reader
1035    /// re-binds on version rotation.
1036    vnode_assignment: Option<(
1037        Arc<laminar_core::state::VnodeRegistry>,
1038        laminar_core::state::NodeId,
1039    )>,
1040    /// Precomputed source/topic/partition routes used by the per-record stale-owner fence.
1041    vnode_partition_routes: KafkaPartitionRoutes,
1042    /// Canonical non-vnode manual assignment used by local guaranteed, specific, and timestamp
1043    /// starts. Checkpointing trusts this engine-owned inventory, never a fallible broker query.
1044    manual_topic_partitions: KafkaPartitionSet,
1045    /// Concrete next-to-read position captured before intake for every engine-owned partition.
1046    /// It remains authoritative until that partition has an accepted-record offset.
1047    manual_partition_baselines: KafkaPartitionBaselines,
1048    /// Exact Kafka ownership and durable handoff positions for one assignment version.
1049    assignment_publication: Arc<Mutex<Arc<KafkaAssignmentPublication>>>,
1050    /// Zero-cost steady-state fast path: avoids locking the rotation snapshot
1051    /// when no acquired partition is waiting for its first accepted record.
1052    rotation_partition_baseline_count: Arc<AtomicUsize>,
1053    /// Rotation publication whose stale local offsets have already been removed.
1054    /// Baseline publication is immutable within one assignment version, so this
1055    /// keeps that cleanup off the steady-state poll path.
1056    applied_rotation_baseline_version: Option<u64>,
1057    /// Assignment version whose Kafka consumer rebind and durable cursor
1058    /// validation have completed. Barriers stay fenced while this trails the
1059    /// registry's atomically published version.
1060    reconciled_assignment_version: Arc<AtomicU64>,
1061
1062    /// Previous Avro writer schema, diffed against the next for evolution detection.
1063    last_avro_schema: Option<SchemaRef>,
1064
1065    // Reusable poll_batch buffers — cleared each cycle, capacity retained.
1066    poll_payloads: Vec<KafkaPayload>,
1067    poll_payload_buf: Vec<u8>,
1068    poll_payload_offsets: Vec<(usize, usize)>,
1069    poll_meta_partitions: Vec<i32>,
1070    poll_meta_offsets: Vec<i64>,
1071    poll_meta_timestamps: Vec<Option<i64>>,
1072    poll_meta_headers: Vec<Option<String>>,
1073    /// This poll's (topic, partition, offset) triples, folded into `offsets` only after the complete
1074    /// output batch is constructed — so decode or finalization failure cannot advance beyond data
1075    /// that was never emitted. Reused across polls.
1076    poll_staged_offsets: Vec<(Arc<str>, i32, i64)>,
1077}
1078
1079impl KafkaSource {
1080    /// Creates a new Kafka source connector with explicit schema.
1081    #[must_use]
1082    pub fn new(
1083        schema: SchemaRef,
1084        config: KafkaSourceConfig,
1085        registry: Option<&prometheus::Registry>,
1086    ) -> Self {
1087        Self::build_base(schema, config, select_deserializer, None, registry)
1088    }
1089
1090    /// Creates a new Kafka source connector with Schema Registry.
1091    #[must_use]
1092    pub fn with_schema_registry(
1093        schema: SchemaRef,
1094        config: KafkaSourceConfig,
1095        sr_client: SchemaRegistryClient,
1096    ) -> Self {
1097        let sr = Arc::new(sr_client);
1098        let sr_clone = Arc::clone(&sr);
1099        let deser_factory = move |format: Format| -> Box<dyn RecordDeserializer> {
1100            if format == Format::Avro {
1101                Box::new(AvroDeserializer::with_schema_registry(sr_clone))
1102            } else {
1103                select_deserializer(format)
1104            }
1105        };
1106        Self::build_base(schema, config, deser_factory, Some(sr), None)
1107    }
1108
1109    /// Build a Schema Registry client from the parsed config, or
1110    /// `Ok(None)` when `schema.registry.url` is not set.
1111    fn build_sr_client(
1112        config: &KafkaSourceConfig,
1113    ) -> Result<Option<SchemaRegistryClient>, ConnectorError> {
1114        let Some(sr_url) = config.schema_registry_url.as_ref() else {
1115            return Ok(None);
1116        };
1117        let client = if let Some(ca) = config.schema_registry_ssl_ca_location.as_deref() {
1118            SchemaRegistryClient::with_tls_mtls(
1119                sr_url.clone(),
1120                config.schema_registry_auth.clone(),
1121                ca,
1122                config.schema_registry_ssl_certificate_location.as_deref(),
1123                config.schema_registry_ssl_key_location.as_deref(),
1124            )?
1125        } else {
1126            SchemaRegistryClient::new(sr_url.clone(), config.schema_registry_auth.clone())?
1127        };
1128        Ok(Some(client))
1129    }
1130
1131    fn build_base(
1132        schema: SchemaRef,
1133        config: KafkaSourceConfig,
1134        deser_factory: impl FnOnce(Format) -> Box<dyn RecordDeserializer>,
1135        schema_registry: Option<Arc<SchemaRegistryClient>>,
1136        registry: Option<&prometheus::Registry>,
1137    ) -> Self {
1138        let deserializer = deser_factory(config.format);
1139        let channel_len = Arc::new(AtomicUsize::new(0));
1140        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
1141        let blocking_guard = task_owner
1142            .track()
1143            .expect("new Kafka source task generation must be live");
1144
1145        Self {
1146            consumer: None,
1147            config,
1148            delivery: DeliveryGuarantee::BestEffort,
1149            deserializer,
1150            offsets: OffsetTracker::new(),
1151            state: ConnectorState::Created,
1152            metrics: KafkaSourceMetrics::new(registry),
1153            schema,
1154            channel_len,
1155            rebalance_state: Arc::new(Mutex::new(RebalanceState::new())),
1156            rebalance_counter: Arc::new(AtomicU64::new(0)),
1157            revoke_generation: Arc::new(AtomicU64::new(0)),
1158            assign_generation: Arc::new(AtomicU64::new(0)),
1159            last_seen_revoke_gen: 0,
1160            schema_registry,
1161            data_ready: Arc::new(Notify::new()),
1162            msg_rx: None,
1163            reader_handle: None,
1164            reader_shutdown: None,
1165            task_owner,
1166            task_tracker,
1167            blocking_tasks: KafkaBlockingTasks::new(blocking_guard),
1168            reader_fault: Arc::new(Mutex::new(None)),
1169            reader_drain_tx: None,
1170            source_drain: None,
1171            offset_snapshot: Arc::new(Mutex::new(OffsetTracker::new())),
1172            deterministic_unrecorded_position: Arc::new(AtomicBool::new(false)),
1173            source_name: Arc::from(""),
1174            vnode_assignment: None,
1175            vnode_partition_routes: KafkaPartitionRoutes::new(),
1176            manual_topic_partitions: std::collections::HashSet::new(),
1177            manual_partition_baselines: std::collections::HashMap::new(),
1178            assignment_publication: Arc::new(Mutex::new(Arc::new(
1179                KafkaAssignmentPublication::default(),
1180            ))),
1181            rotation_partition_baseline_count: Arc::new(AtomicUsize::new(0)),
1182            applied_rotation_baseline_version: None,
1183            reconciled_assignment_version: Arc::new(AtomicU64::new(0)),
1184            last_avro_schema: None,
1185            poll_payloads: Vec::new(),
1186            poll_payload_buf: Vec::new(),
1187            poll_payload_offsets: Vec::new(),
1188            poll_meta_partitions: Vec::new(),
1189            poll_meta_offsets: Vec::new(),
1190            poll_meta_timestamps: Vec::new(),
1191            poll_meta_headers: Vec::new(),
1192            poll_staged_offsets: Vec::new(),
1193        }
1194    }
1195
1196    /// Lifecycle state (Created → Initializing → Running → Closed).
1197    #[must_use]
1198    pub fn state(&self) -> ConnectorState {
1199        self.state
1200    }
1201
1202    /// Shared backpressure fill counter for downstream wiring.
1203    #[must_use]
1204    pub fn channel_len(&self) -> Arc<AtomicUsize> {
1205        Arc::clone(&self.channel_len)
1206    }
1207
1208    /// Shared partition assignment state (updated by rebalance callbacks).
1209    #[must_use]
1210    pub fn rebalance_state(&self) -> Arc<Mutex<RebalanceState>> {
1211        Arc::clone(&self.rebalance_state)
1212    }
1213
1214    /// Whether a Schema Registry client is configured.
1215    #[must_use]
1216    pub fn has_schema_registry(&self) -> bool {
1217        self.schema_registry.is_some()
1218    }
1219
1220    fn fail_startup(&mut self) {
1221        self.state = ConnectorState::Failed;
1222        self.blocking_tasks.retire();
1223        self.blocking_tasks.ensure_reaper();
1224        if let Some(consumer) = self.consumer.take() {
1225            consumer.unsubscribe();
1226            self.blocking_tasks
1227                .spawn_final_drop(consumer, "consumer after failed startup");
1228        }
1229    }
1230
1231    fn capture_drain_positions(
1232        &self,
1233        inputs: &[KafkaDrainPartition],
1234        prepare_deadline: Option<tokio::time::Instant>,
1235    ) -> Result<Arc<[KafkaDrainPosition]>, ConnectorError> {
1236        if prepare_deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
1237            return Err(ConnectorError::Internal(
1238                "Kafka drain deadline expired before cursor capture".into(),
1239            ));
1240        }
1241        let publication = Arc::clone(&lock_or_recover(&self.assignment_publication));
1242        let mut cut = Vec::with_capacity(inputs.len());
1243        for input in inputs {
1244            if prepare_deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
1245                return Err(ConnectorError::Internal(
1246                    "Kafka drain deadline expired during cursor capture".into(),
1247                ));
1248            }
1249            let next_offset =
1250                if let Some(offset) = self.offsets.get(input.topic.as_ref(), input.partition) {
1251                    offset.checked_add(1).ok_or_else(|| {
1252                        ConnectorError::Internal(format!(
1253                            "Kafka drain offset overflow for '{}-{}'",
1254                            input.topic, input.partition
1255                        ))
1256                    })?
1257                } else if let Some(next) = rotation_partition_baseline(
1258                    &publication.baselines,
1259                    input.topic.as_ref(),
1260                    input.partition,
1261                ) {
1262                    next
1263                } else if let Some(next) = self
1264                    .manual_partition_baselines
1265                    .get(&(input.topic.to_string(), input.partition))
1266                {
1267                    *next
1268                } else {
1269                    return Err(ConnectorError::InvalidState {
1270                        expected: format!(
1271                            "numeric next-to-read position for drained Kafka input '{}-{}'",
1272                            input.topic, input.partition
1273                        ),
1274                        actual: "no accepted offset or deterministic baseline".into(),
1275                    });
1276                };
1277            cut.push(KafkaDrainPosition {
1278                topic: Arc::clone(&input.topic),
1279                partition: input.partition,
1280                next_offset,
1281            });
1282        }
1283        if prepare_deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
1284            return Err(ConnectorError::Internal(
1285                "Kafka drain deadline expired during cursor capture".into(),
1286            ));
1287        }
1288        Ok(cut.into())
1289    }
1290
1291    fn check_reader_health(&self, operation: &str) -> Result<(), ConnectorError> {
1292        if let Some(reason) = lock_or_recover(&self.reader_fault).clone() {
1293            return Err(ConnectorError::Internal(format!(
1294                "Kafka reader terminated while {operation}: {reason}"
1295            )));
1296        }
1297        if self
1298            .reader_handle
1299            .as_ref()
1300            .is_some_and(tokio::task::JoinHandle::is_finished)
1301        {
1302            return Err(ConnectorError::Internal(format!(
1303                "Kafka reader task exited while {operation}"
1304            )));
1305        }
1306        Ok(())
1307    }
1308
1309    fn validate_active_drain_cursor(&self) -> Result<(), ConnectorError> {
1310        self.check_reader_health("validating a drain cursor")?;
1311        let Some(active) = self.source_drain.as_ref() else {
1312            return Ok(());
1313        };
1314        let Some(expected) = active.cut.as_deref() else {
1315            return Ok(());
1316        };
1317        let Some(boundary) = active.boundary.as_ref() else {
1318            return Err(ConnectorError::Internal(
1319                "Kafka drain cut exists without its FIFO boundary".into(),
1320            ));
1321        };
1322        // The certified cut remains valid while terminal materialization is pending; the
1323        // preparation deadline no longer applies after the cut has been captured.
1324        let current = self.capture_drain_positions(&boundary.inputs, None)?;
1325        if current.as_ref() != expected {
1326            return Err(ConnectorError::Internal(
1327                "Kafka input cursor advanced after its drain receipt".into(),
1328            ));
1329        }
1330        Ok(())
1331    }
1332
1333    /// Spawns the background reader task on the first `poll_batch()`.
1334    /// The startup cursor has already been installed by `start()` before these
1335    /// tasks can observe the consumer.
1336    fn ensure_reader_started(&mut self) {
1337        if self.reader_handle.is_some() || self.consumer.is_none() {
1338            return;
1339        }
1340
1341        let consumer = Arc::clone(self.consumer.as_ref().unwrap());
1342        // Drain control exists only on a cluster-assigned source. Embedded and single-node
1343        // readers retain their existing allocation-free control path.
1344        let vnode_reassign = self
1345            .vnode_assignment
1346            .as_ref()
1347            .map(|(r, s)| (Arc::clone(r), *s));
1348        let (msg_tx, msg_rx) =
1349            crossfire::mpsc::bounded_async::<KafkaReaderItem>(self.config.reader_channel_capacity);
1350        let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
1351        let (reader_drain_tx, mut reader_drain_rx) = if vnode_reassign.is_some() {
1352            let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1353            (Some(tx), Some(rx))
1354        } else {
1355            (None, None)
1356        };
1357        let data_ready = Arc::clone(&self.data_ready);
1358        let reader_fault = Arc::clone(&self.reader_fault);
1359        let channel_len = Arc::clone(&self.channel_len);
1360        let capture_headers = self.config.include_headers;
1361        let reader_channel_capacity = self.config.reader_channel_capacity;
1362        let reader_channel_capacity_f64 =
1363            u32::try_from(reader_channel_capacity).map_or(f64::from(u32::MAX), f64::from);
1364        let assign_generation = Arc::clone(&self.assign_generation);
1365        let rebalance_state = Arc::clone(&self.rebalance_state);
1366        let pause_threshold = self.config.backpressure_high_watermark;
1367        let resume_threshold = self.config.backpressure_low_watermark;
1368
1369        // -- Reader task: message consumption, backpressure, revoke pruning --
1370        // Engine-controlled re-assignment inputs (cluster mode; `None` otherwise).
1371        let vnode_partition_routes = std::mem::take(&mut self.vnode_partition_routes);
1372        let reassign_snapshot = Arc::clone(&self.offset_snapshot);
1373        let reassign_baselines = self.manual_partition_baselines.clone();
1374        let assignment_publication = Arc::clone(&self.assignment_publication);
1375        let rotation_baseline_count = Arc::clone(&self.rotation_partition_baseline_count);
1376        let reconciled_assignment_version = Arc::clone(&self.reconciled_assignment_version);
1377        let require_durable_baselines = !reassign_baselines.is_empty();
1378        let reassign_default_offset = startup_default_offset(&self.config.startup_mode);
1379        let deterministic_unrecorded = Arc::clone(&self.deterministic_unrecorded_position);
1380        let source_name = Arc::clone(&self.source_name);
1381        let blocking_tasks = self.blocking_tasks.clone();
1382        let deterministic_default =
1383            deterministic_initial_offset(&self.config.startup_mode, self.config.auto_offset_reset);
1384        let mut reader_shutdown = shutdown_rx;
1385        let reader_guard = self
1386            .task_owner
1387            .track()
1388            .expect("live Kafka source must admit its reader task");
1389        let reader_handle = tokio::spawn(async move {
1390            let _reader_guard = reader_guard;
1391            let mut cached_topic: Arc<str> = Arc::from("");
1392            let mut cached_topic_routes: Option<Arc<[u32]>> = None;
1393            let mut is_paused = false;
1394            let mut last_assign_gen: u64 = 0;
1395            // start() records the exact publication used for its initial Kafka assignment.
1396            // Starting from that fence detects even a self→other→self sequence that lands before
1397            // this lazy reader gets its first turn; boot-unassigned sources legitimately start 0.
1398            let mut last_assignment_version = reconciled_assignment_version.load(Ordering::Acquire);
1399            let mut drain_paused: std::collections::HashSet<(Arc<str>, i32)> =
1400                std::collections::HashSet::new();
1401            let mut active_drain: Option<KafkaReaderDrain> = None;
1402            let mut deferred_drain_command = None;
1403
1404            loop {
1405                if active_drain.as_ref().is_some_and(|active| {
1406                    !active.boundary_queued
1407                        && tokio::time::Instant::now() >= active.prepare_deadline
1408                }) {
1409                    publish_reader_fault(
1410                        &reader_fault,
1411                        &data_ready,
1412                        "Kafka source drain preparation exceeded its engine deadline",
1413                    );
1414                    return;
1415                }
1416                // On rotation, apply only the DELTA (incremental assign/unassign): a
1417                // full re-assign would re-seek kept partitions and re-fetch records
1418                // already buffered ahead, re-emitting committed rows. Newly-acquired
1419                // partitions seek to their handoff offset; kept ones are untouched.
1420                if let Some((registry, self_id)) = &vnode_reassign {
1421                    let published = registry.versioned_snapshot();
1422                    let version = published.version();
1423                    if version != last_assignment_version {
1424                        let current = match consumer.assignment() {
1425                            Ok(current) => current,
1426                            Err(error) => {
1427                                warn!(
1428                                    version,
1429                                    %error,
1430                                    "Kafka source could not inspect its current assignment; rotation will retry"
1431                                );
1432                                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1433                                continue;
1434                            }
1435                        };
1436                        // A source can miss an intermediate self→other→self publication while
1437                        // fenced (for example consecutive dead-node shedding). The latest owner
1438                        // set then matches the live Kafka assignment even though the partition
1439                        // must be unassigned and re-seeked to the latest durable handoff.
1440                        let current_set = match kafka_partition_set(&current) {
1441                            Ok(current) => current,
1442                            Err(error) => {
1443                                publish_reader_fault(&reader_fault, &data_ready, error);
1444                                return;
1445                            }
1446                        };
1447                        let (owned_set, reacquired) = match kafka_owned_partition_sets(
1448                            &vnode_partition_routes,
1449                            &published,
1450                            *self_id,
1451                            last_assignment_version,
1452                        ) {
1453                            Ok(partitions) => partitions,
1454                            Err(error) => {
1455                                warn!(
1456                                    source = source_name.as_ref(),
1457                                    %error,
1458                                    "Kafka source rejected its cached partition routes"
1459                                );
1460                                publish_reader_fault(
1461                                    &reader_fault,
1462                                    &data_ready,
1463                                    format!("invalid cached partition route: {error}"),
1464                                );
1465                                return;
1466                            }
1467                        };
1468
1469                        let mut to_remove = TopicPartitionList::new();
1470                        for (topic, partition) in current_set
1471                            .difference(&owned_set)
1472                            .chain(reacquired.intersection(&current_set))
1473                        {
1474                            to_remove.add_partition(topic, *partition);
1475                        }
1476
1477                        let offsets = lock_or_recover(&reassign_snapshot).clone();
1478                        let (resume, resume_baselines) = if let Some(handoff) =
1479                            published.committed_source_handoff()
1480                        {
1481                            match decode_committed_kafka_handoff(handoff, source_name.as_ref()) {
1482                                Ok(state) => state,
1483                                Err(error) => {
1484                                    warn!(
1485                                        version,
1486                                        source = source_name.as_ref(),
1487                                        error = %error,
1488                                        "Kafka source rejected durable checkpoint handoff"
1489                                    );
1490                                    publish_reader_fault(
1491                                        &reader_fault,
1492                                        &data_ready,
1493                                        format!("invalid durable checkpoint handoff: {error}"),
1494                                    );
1495                                    return;
1496                                }
1497                            }
1498                        } else {
1499                            (OffsetTracker::new(), KafkaPartitionBaselines::new())
1500                        };
1501                        let mut to_add = TopicPartitionList::new();
1502                        let mut acquired_positions = KafkaPartitionBaselines::new();
1503                        for (topic, partition) in owned_set
1504                            .difference(&current_set)
1505                            .chain(reacquired.intersection(&current_set))
1506                        {
1507                            let p = *partition;
1508                            let offset = if let Some(next) = match acquired_numeric_position(
1509                                &resume,
1510                                &resume_baselines,
1511                                &offsets,
1512                                &reassign_baselines,
1513                                topic.as_str(),
1514                                p,
1515                                !require_durable_baselines || !published.has_committed_handoff(),
1516                            ) {
1517                                Ok(position) => position,
1518                                Err(error) => {
1519                                    warn!(
1520                                        topic = topic.as_str(),
1521                                        partition = p,
1522                                        %error,
1523                                        "Kafka source rejected an invalid handoff position"
1524                                    );
1525                                    publish_reader_fault(
1526                                        &reader_fault,
1527                                        &data_ready,
1528                                        format!("invalid handoff position: {error}"),
1529                                    );
1530                                    return;
1531                                }
1532                            } {
1533                                info!(
1534                                    topic = topic.as_str(),
1535                                    partition = p,
1536                                    resume = next,
1537                                    "acquired partition uses durable numeric position"
1538                                );
1539                                acquired_positions.insert((topic.clone(), p), next);
1540                                rdkafka::Offset::Offset(next)
1541                            } else if require_durable_baselines {
1542                                warn!(
1543                                    topic = topic.as_str(),
1544                                    partition = p,
1545                                    "acquired Kafka partition has no durable next-to-read baseline"
1546                                );
1547                                publish_reader_fault(
1548                                    &reader_fault,
1549                                    &data_ready,
1550                                    "acquired partition has no durable baseline",
1551                                );
1552                                return;
1553                            } else if deterministic_unrecorded.load(Ordering::Acquire) {
1554                                let Some(initial_offset) = deterministic_default else {
1555                                    warn!(
1556                                            topic = topic.as_str(),
1557                                            partition = p,
1558                                            "cannot deterministically position acquired Kafka partition"
1559                                        );
1560                                    publish_reader_fault(
1561                                        &reader_fault,
1562                                        &data_ready,
1563                                        "acquired partition has no deterministic position",
1564                                    );
1565                                    return;
1566                                };
1567                                initial_offset
1568                            } else {
1569                                warn!(
1570                                    topic = topic.as_str(),
1571                                    partition = p,
1572                                    "acquired partition has no handoff or local offset; \
1573                                         falling back to the startup default"
1574                                );
1575                                reassign_default_offset
1576                            };
1577                            if let Err(error) =
1578                                to_add.add_partition_offset(topic.as_str(), p, offset)
1579                            {
1580                                warn!(
1581                                    topic = topic.as_str(),
1582                                    partition = p,
1583                                    %error,
1584                                    "failed to build Kafka rotation assignment"
1585                                );
1586                                publish_reader_fault(
1587                                    &reader_fault,
1588                                    &data_ready,
1589                                    format!("invalid rotation assignment: {error}"),
1590                                );
1591                                return;
1592                            }
1593                        }
1594
1595                        let owned_set = Arc::new(owned_set);
1596
1597                        // Registry ownership is already committed. Publish the handoff cut before
1598                        // the first await so a concurrent checkpoint cannot resurrect a stale
1599                        // position from this node's earlier ownership stint.
1600                        {
1601                            let mut current = lock_or_recover(&assignment_publication);
1602                            let updated = update_rotation_baselines(
1603                                &current.baselines,
1604                                &owned_set,
1605                                &acquired_positions,
1606                            );
1607                            let count = rotation_baselines_len(&updated);
1608                            *current = Arc::new(KafkaAssignmentPublication::new(
1609                                version,
1610                                Arc::clone(&owned_set),
1611                                updated,
1612                            ));
1613                            rotation_baseline_count.store(count, Ordering::Release);
1614                        }
1615
1616                        if !acquired_positions.is_empty() {
1617                            let acquired: KafkaPartitionSet =
1618                                acquired_positions.keys().cloned().collect();
1619                            let low_watermarks = match fetch_partition_low_watermarks(
1620                                blocking_tasks.clone(),
1621                                Arc::clone(&consumer),
1622                                &acquired,
1623                            )
1624                            .await
1625                            {
1626                                Ok(low_watermarks) => low_watermarks,
1627                                Err(error) => {
1628                                    warn!(
1629                                        version,
1630                                        %error,
1631                                        "Kafka source could not validate acquired handoff positions; rotation will retry"
1632                                    );
1633                                    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1634                                    continue;
1635                                }
1636                            };
1637                            if let Err(error) = validate_positions_not_expired(
1638                                &OffsetTracker::new(),
1639                                &acquired_positions,
1640                                &low_watermarks,
1641                                &acquired,
1642                            ) {
1643                                warn!(
1644                                    version,
1645                                    %error,
1646                                    "Kafka source rejected an expired handoff position"
1647                                );
1648                                publish_reader_fault(
1649                                    &reader_fault,
1650                                    &data_ready,
1651                                    format!("expired handoff position: {error}"),
1652                                );
1653                                return;
1654                            }
1655                        }
1656
1657                        let mut ok = true;
1658                        if to_remove.count() > 0 {
1659                            match consumer.incremental_unassign(&to_remove) {
1660                                Ok(()) => {
1661                                    if let Err(error) = validate_kafka_partition_results(
1662                                        "incremental unassign",
1663                                        &to_remove,
1664                                    ) {
1665                                        warn!(version, %error, "Kafka source unassign incomplete");
1666                                        ok = false;
1667                                    }
1668                                }
1669                                Err(e) => {
1670                                    warn!(version, error = %e, "Kafka source unassign failed");
1671                                    ok = false;
1672                                }
1673                            }
1674                        }
1675                        if to_add.count() > 0 {
1676                            match consumer.incremental_assign(&to_add) {
1677                                Ok(()) => {
1678                                    if let Err(error) = validate_kafka_partition_results(
1679                                        "incremental assign",
1680                                        &to_add,
1681                                    ) {
1682                                        warn!(version, %error, "Kafka source assign incomplete");
1683                                        ok = false;
1684                                    }
1685                                }
1686                                Err(e) => {
1687                                    warn!(version, error = %e, "Kafka source assign failed");
1688                                    ok = false;
1689                                }
1690                            }
1691                        }
1692                        if ok {
1693                            match consumer.assignment() {
1694                                Ok(active) => {
1695                                    match kafka_partition_set(&active).and_then(|active| {
1696                                        validate_kafka_assignment(&owned_set, &active)
1697                                    }) {
1698                                        Ok(()) => {}
1699                                        Err(error) => {
1700                                            warn!(
1701                                                version,
1702                                                %error,
1703                                                "Kafka source assignment verification failed"
1704                                            );
1705                                            ok = false;
1706                                        }
1707                                    }
1708                                }
1709                                Err(error) => {
1710                                    warn!(
1711                                        version,
1712                                        %error,
1713                                        "Kafka source could not verify its rebound assignment"
1714                                    );
1715                                    ok = false;
1716                                }
1717                            }
1718                        }
1719                        if !ok {
1720                            match tokio::time::timeout(
1721                                std::time::Duration::from_millis(10),
1722                                reader_shutdown.changed(),
1723                            )
1724                            .await
1725                            {
1726                                Ok(Ok(())) if *reader_shutdown.borrow() => break,
1727                                Ok(Err(_)) => break,
1728                                _ => {}
1729                            }
1730                            continue;
1731                        }
1732
1733                        // Do not expose a successfully rebound but already obsolete target.
1734                        let current_assignment = registry.read_assignment();
1735                        if current_assignment.version() != version {
1736                            continue;
1737                        }
1738                        last_assignment_version = version;
1739                        reconciled_assignment_version.store(version, Ordering::Release);
1740                        drop(current_assignment);
1741                        if to_remove.count() > 0 || to_add.count() > 0 {
1742                            info!(
1743                                version,
1744                                acquired = to_add.count(),
1745                                revoked = to_remove.count(),
1746                                "Kafka source rebound partitions after vnode rotation"
1747                            );
1748                        }
1749                        // Reassignment can clear librdkafka's pause bit even when the same input
1750                        // is re-acquired. Require every added input to prove its target pause.
1751                        for element in to_add.elements() {
1752                            drain_paused.remove(&(Arc::from(element.topic()), element.partition()));
1753                        }
1754                        drain_paused.retain(|(t, p)| owned_set.contains(&(t.to_string(), *p)));
1755                        if let Some(active) = active_drain.as_mut() {
1756                            active.held_assignment_version = None;
1757                            active.hold_complete = false;
1758                        }
1759                        // A stale stint position must not shadow the handoff on re-acquire.
1760                        lock_or_recover(&reassign_snapshot).retain_assigned(&owned_set);
1761                        data_ready.notify_one();
1762                    }
1763                }
1764
1765                // Exact source drain control is deliberately independent of assignment gossip.
1766                // A retained source-task command names the predecessor/target/leader round.
1767                let command = deferred_drain_command.take().or_else(|| {
1768                    reader_drain_rx
1769                        .as_mut()
1770                        .and_then(|receiver| receiver.try_recv().ok())
1771                });
1772                if let Some(command) = command {
1773                    match command {
1774                        KafkaReaderDrainCommand::Begin { request, deadline } => {
1775                            if let Some(current) = active_drain.as_ref() {
1776                                if current.request != request {
1777                                    warn!(
1778                                        current = ?current.request.round,
1779                                        requested = ?request.round,
1780                                        "Kafka reader received a conflicting drain round"
1781                                    );
1782                                    publish_reader_fault(
1783                                        &reader_fault,
1784                                        &data_ready,
1785                                        "conflicting Kafka drain round",
1786                                    );
1787                                    return;
1788                                }
1789                            } else {
1790                                if tokio::time::Instant::now() >= deadline {
1791                                    publish_reader_fault(
1792                                        &reader_fault,
1793                                        &data_ready,
1794                                        "Kafka source drain began after its engine deadline",
1795                                    );
1796                                    return;
1797                                }
1798                                let Some((registry, _)) = vnode_reassign.as_ref() else {
1799                                    warn!("Kafka reader received drain control without cluster ownership");
1800                                    publish_reader_fault(
1801                                        &reader_fault,
1802                                        &data_ready,
1803                                        "drain control has no cluster ownership",
1804                                    );
1805                                    return;
1806                                };
1807                                if registry.assignment_version()
1808                                    != request.round.predecessor_version
1809                                {
1810                                    warn!(
1811                                        current = registry.assignment_version(),
1812                                        predecessor = request.round.predecessor_version,
1813                                        "Kafka reader rejected drain for a stale predecessor"
1814                                    );
1815                                    publish_reader_fault(
1816                                        &reader_fault,
1817                                        &data_ready,
1818                                        "drain predecessor does not match current assignment",
1819                                    );
1820                                    return;
1821                                }
1822                                if last_assignment_version != request.round.predecessor_version {
1823                                    publish_reader_fault(
1824                                        &reader_fault,
1825                                        &data_ready,
1826                                        "Kafka drain predecessor is not reconciled",
1827                                    );
1828                                    return;
1829                                }
1830                                let assignment = match consumer.assignment() {
1831                                    Ok(assignment) => assignment,
1832                                    Err(error) => {
1833                                        publish_reader_fault(
1834                                            &reader_fault,
1835                                            &data_ready,
1836                                            format!(
1837                                                "Kafka drain could not inspect its assignment: {error}"
1838                                            ),
1839                                        );
1840                                        return;
1841                                    }
1842                                };
1843                                let inputs = match kafka_drain_partitions(&assignment) {
1844                                    Ok(inputs) => inputs,
1845                                    Err(error) => {
1846                                        warn!(
1847                                            source = source_name.as_ref(),
1848                                            %error,
1849                                            "Kafka reader rejected its drain assignment"
1850                                        );
1851                                        publish_reader_fault(
1852                                            &reader_fault,
1853                                            &data_ready,
1854                                            format!("invalid Kafka drain assignment: {error}"),
1855                                        );
1856                                        return;
1857                                    }
1858                                };
1859                                active_drain = Some(KafkaReaderDrain {
1860                                    request,
1861                                    prepare_deadline: deadline,
1862                                    held_inputs: Arc::clone(&inputs),
1863                                    inputs,
1864                                    held_assignment_version: Some(last_assignment_version),
1865                                    hold_complete: false,
1866                                    boundary_queued: false,
1867                                });
1868                            }
1869                        }
1870                        KafkaReaderDrainCommand::Resolve {
1871                            resolution,
1872                            cut,
1873                            deadline,
1874                            execution,
1875                            reply,
1876                        } => {
1877                            if active_drain.is_none() {
1878                                let _ = reply.send(Err(
1879                                    "Kafka reader has no active drain to resolve".into(),
1880                                ));
1881                                continue;
1882                            }
1883                            if tokio::time::Instant::now() >= deadline {
1884                                let _ = reply.send(Err(
1885                                    "Kafka drain deadline expired before target reconciliation"
1886                                        .into(),
1887                                ));
1888                                continue;
1889                            }
1890                            let Some((registry, _)) = vnode_reassign.as_ref() else {
1891                                let _ = reply.send(Err(
1892                                    "Kafka drain resolution has no cluster assignment".into(),
1893                                ));
1894                                continue;
1895                            };
1896                            let target_ready = match kafka_drain_target_ready(
1897                                resolution.round.target_version,
1898                                registry.assignment_version(),
1899                                last_assignment_version,
1900                            ) {
1901                                Ok(ready) => ready,
1902                                Err(error) => {
1903                                    let _ = reply.send(Err(error));
1904                                    continue;
1905                                }
1906                            };
1907                            let target_paused = target_ready
1908                                && active_drain.as_ref().is_some_and(|active| {
1909                                    active.held_assignment_version
1910                                        == Some(resolution.round.target_version)
1911                                        && active.hold_complete
1912                                });
1913                            if !target_ready || !target_paused {
1914                                deferred_drain_command = Some(KafkaReaderDrainCommand::Resolve {
1915                                    resolution,
1916                                    cut,
1917                                    deadline,
1918                                    execution,
1919                                    reply,
1920                                });
1921                            } else {
1922                                let result = resolve_kafka_reader_drain(
1923                                    &consumer,
1924                                    &blocking_tasks,
1925                                    vnode_reassign.as_ref(),
1926                                    active_drain.as_ref().expect("validated above"),
1927                                    resolution,
1928                                    &cut,
1929                                    is_paused,
1930                                    deadline,
1931                                    &execution,
1932                                )
1933                                .await;
1934                                if result.is_ok() {
1935                                    active_drain = None;
1936                                    drain_paused.clear();
1937                                }
1938                                let _ = reply.send(result);
1939                                continue;
1940                            }
1941                        }
1942                    }
1943                }
1944
1945                // Hold the complete live Kafka assignment for the full source cut. Assignment
1946                // reconciliation can replace inputs while the cut is held, so new inputs are
1947                // paused before the reader is allowed to receive from them.
1948                if let Some(active) = active_drain.as_mut() {
1949                    if active.held_assignment_version != Some(last_assignment_version) {
1950                        let assignment = match consumer.assignment() {
1951                            Ok(assignment) => assignment,
1952                            Err(error) => {
1953                                warn!(%error, "Kafka drain could not inspect the live assignment");
1954                                continue;
1955                            }
1956                        };
1957                        active.held_inputs = match kafka_drain_partitions(&assignment) {
1958                            Ok(inputs) => inputs,
1959                            Err(error) => {
1960                                publish_reader_fault(
1961                                    &reader_fault,
1962                                    &data_ready,
1963                                    format!("invalid Kafka assignment while draining: {error}"),
1964                                );
1965                                return;
1966                            }
1967                        };
1968                        let current_set: std::collections::HashSet<(Arc<str>, i32)> = active
1969                            .held_inputs
1970                            .iter()
1971                            .map(|input| (Arc::clone(&input.topic), input.partition))
1972                            .collect();
1973                        drain_paused.retain(|input| current_set.contains(input));
1974                        active.held_assignment_version = Some(last_assignment_version);
1975                        active.hold_complete = false;
1976                    }
1977                    if !active.hold_complete {
1978                        let remaining: Vec<(Arc<str>, i32)> = active
1979                            .held_inputs
1980                            .iter()
1981                            .filter(|input| {
1982                                !drain_paused.contains(&(Arc::clone(&input.topic), input.partition))
1983                            })
1984                            .map(|input| (Arc::clone(&input.topic), input.partition))
1985                            .collect();
1986                        let to_pause = tpl_of(remaining.iter());
1987                        if to_pause.count() > 0 {
1988                            match consumer.pause(&to_pause) {
1989                                Ok(()) => {
1990                                    for element in to_pause.elements() {
1991                                        if element.error().is_ok() {
1992                                            drain_paused.insert((
1993                                                Arc::from(element.topic()),
1994                                                element.partition(),
1995                                            ));
1996                                        }
1997                                    }
1998                                    if let Err(error) =
1999                                        validate_kafka_partition_results("drain pause", &to_pause)
2000                                    {
2001                                        warn!(%error, "Kafka drain pause incomplete; will retry");
2002                                        continue;
2003                                    }
2004                                }
2005                                Err(error) => {
2006                                    warn!(%error, "Kafka drain pause failed; will retry");
2007                                    continue;
2008                                }
2009                            }
2010                        }
2011                        active.hold_complete = active.held_inputs.iter().all(|input| {
2012                            drain_paused.contains(&(Arc::clone(&input.topic), input.partition))
2013                        });
2014                    }
2015                    if !active.boundary_queued {
2016                        let cut_is_paused = active.inputs.iter().all(|input| {
2017                            drain_paused.contains(&(Arc::clone(&input.topic), input.partition))
2018                        });
2019                        if cut_is_paused {
2020                            let boundary = KafkaDrainBoundary {
2021                                round: active.request.round,
2022                                inputs: Arc::clone(&active.inputs),
2023                            };
2024                            channel_len.fetch_add(1, Ordering::Relaxed);
2025                            let sent = tokio::select! {
2026                                biased;
2027                                _ = reader_shutdown.changed() => false,
2028                                () = tokio::time::sleep_until(active.prepare_deadline) => false,
2029                                result = msg_tx.send(KafkaReaderItem::DrainBoundary(boundary)) => result.is_ok(),
2030                            };
2031                            if !sent {
2032                                channel_len.fetch_sub(1, Ordering::Relaxed);
2033                                if tokio::time::Instant::now() >= active.prepare_deadline {
2034                                    publish_reader_fault(
2035                                        &reader_fault,
2036                                        &data_ready,
2037                                        "Kafka drain boundary delivery exceeded its engine deadline",
2038                                    );
2039                                } else if !*reader_shutdown.borrow() {
2040                                    publish_reader_fault(
2041                                        &reader_fault,
2042                                        &data_ready,
2043                                        "reader drain-boundary channel closed unexpectedly",
2044                                    );
2045                                }
2046                                break;
2047                            }
2048                            active.boundary_queued = true;
2049                            data_ready.notify_one();
2050                        }
2051                    }
2052                }
2053
2054                // Newly-assigned partitions were paused by the callback. Seek to the
2055                // checkpointed offsets HERE, in the poll loop where the assignment is
2056                // fetch-ready (the in-callback seek fails `Local: Erroneous state`),
2057                // then resume — otherwise recovery resumes from auto.offset.reset.
2058                let cur_assign_gen = assign_generation.load(Ordering::Acquire);
2059                if cur_assign_gen != last_assign_gen {
2060                    let mut assigned: Vec<(String, i32)> = lock_or_recover(&rebalance_state)
2061                        .assigned_partitions()
2062                        .iter()
2063                        .cloned()
2064                        .collect();
2065                    if assigned.is_empty() {
2066                        // Vnode mode fires no rebalance callback, so use the live
2067                        // assignment to apply the cursor staged by start().
2068                        if let Ok(a) = consumer.assignment() {
2069                            assigned = a
2070                                .elements()
2071                                .iter()
2072                                .map(|e| (e.topic().to_string(), e.partition()))
2073                                .collect();
2074                        }
2075                    }
2076                    let deterministic_fallback = if deterministic_unrecorded.load(Ordering::Acquire)
2077                    {
2078                        deterministic_default
2079                    } else {
2080                        None
2081                    };
2082                    let seek_tpl = match assignment_seek_tpl(
2083                        &lock_or_recover(&reassign_snapshot),
2084                        &assigned,
2085                        if reassign_baselines.is_empty() {
2086                            None
2087                        } else {
2088                            Some(&reassign_baselines)
2089                        },
2090                        deterministic_fallback,
2091                        deterministic_unrecorded.load(Ordering::Acquire),
2092                    ) {
2093                        Ok(tpl) => tpl,
2094                        Err(e) => {
2095                            warn!(error = %e, "failed to build Kafka recovery assignment");
2096                            publish_reader_fault(
2097                                &reader_fault,
2098                                &data_ready,
2099                                format!("invalid recovery assignment: {e}"),
2100                            );
2101                            return;
2102                        }
2103                    };
2104                    let seek_ok = if seek_tpl.count() == 0 {
2105                        true // fresh start: nothing checkpointed to seek to
2106                    } else {
2107                        let seek_consumer = Arc::clone(&consumer);
2108                        match blocking_tasks
2109                            .run(move || {
2110                                seek_consumer
2111                                    .seek_partitions(seek_tpl, std::time::Duration::from_secs(5))
2112                            })
2113                            .await
2114                        {
2115                            Ok(Ok(result))
2116                                if assign_generation.load(Ordering::Acquire) == cur_assign_gen =>
2117                            {
2118                                let failed = result
2119                                    .elements()
2120                                    .iter()
2121                                    .filter(|e| e.error().is_err())
2122                                    .count();
2123                                if failed == 0 {
2124                                    info!(
2125                                        partition_count = result.count(),
2126                                        "seeked assigned partitions to checkpointed offsets"
2127                                    );
2128                                    true
2129                                } else {
2130                                    // Not all fetch-ready yet — retry next loop.
2131                                    debug!(failed, "assign-seek incomplete; will retry");
2132                                    false
2133                                }
2134                            }
2135                            Ok(Ok(_)) => false,
2136                            Ok(Err(e)) => {
2137                                debug!(error = %e, "assign-seek failed; will retry");
2138                                false
2139                            }
2140                            Err(error) => {
2141                                warn!(%error, "Kafka assign-seek worker failed");
2142                                if error == KafkaBlockingTaskError::Retired {
2143                                    return;
2144                                }
2145                                false
2146                            }
2147                        }
2148                    };
2149                    if seek_ok {
2150                        // Positioned — undo the callback's pause only when neither
2151                        // backpressure nor a global source cut is holding intake.
2152                        let mut resumed_ok = true;
2153                        if !is_paused && active_drain.is_none() {
2154                            if let Ok(assignment) = consumer.assignment() {
2155                                if let Err(e) = consumer.resume(&assignment) {
2156                                    warn!(error = %e, "post-seek resume failed; will retry");
2157                                    resumed_ok = false;
2158                                } else if let Err(error) = validate_kafka_partition_results(
2159                                    "post-seek resume",
2160                                    &assignment,
2161                                ) {
2162                                    warn!(%error, "post-seek resume incomplete; will retry");
2163                                    resumed_ok = false;
2164                                }
2165                            }
2166                        }
2167                        if resumed_ok {
2168                            last_assign_gen = cur_assign_gen;
2169                        } else {
2170                            continue;
2171                        }
2172                    }
2173                }
2174
2175                // Backpressure: pause/resume Kafka partitions based on channel fill.
2176                let fill = if reader_channel_capacity > 0 {
2177                    let channel_len = u32::try_from(channel_len.load(Ordering::Acquire))
2178                        .map_or(f64::from(u32::MAX), f64::from);
2179                    channel_len / reader_channel_capacity_f64
2180                } else {
2181                    0.0
2182                };
2183                if active_drain.is_none() && fill >= pause_threshold && !is_paused {
2184                    if let Ok(assignment) = consumer.assignment() {
2185                        match consumer.pause(&assignment) {
2186                            Ok(()) => match validate_kafka_partition_results(
2187                                "backpressure pause",
2188                                &assignment,
2189                            ) {
2190                                Ok(()) => {
2191                                    is_paused = true;
2192                                    debug!("reader: paused Kafka partitions (fill={fill:.2})");
2193                                }
2194                                Err(error) => {
2195                                    warn!(%error, "reader backpressure pause incomplete");
2196                                }
2197                            },
2198                            Err(error) => {
2199                                warn!(%error, "reader backpressure pause failed");
2200                            }
2201                        }
2202                    }
2203                } else if active_drain.is_none() && fill <= resume_threshold && is_paused {
2204                    if let Ok(assignment) = consumer.assignment() {
2205                        let resumed = consumer
2206                            .resume(&assignment)
2207                            .map_err(|error| format!("Kafka backpressure resume failed: {error}"))
2208                            .and_then(|()| {
2209                                validate_kafka_partition_results("backpressure resume", &assignment)
2210                            });
2211                        if let Err(error) = resumed {
2212                            warn!(%error, "reader backpressure resume incomplete");
2213                            continue;
2214                        }
2215                        is_paused = false;
2216                        debug!("reader: resumed Kafka partitions (fill={fill:.2})");
2217                    }
2218                }
2219
2220                // While paused, recv() yields nothing, so a long timeout would
2221                // gate the resume re-check at the top of the loop behind it.
2222                // Poll briefly when paused so resume fires promptly; block
2223                // longer when running so an idle topic doesn't spin.
2224                let recv_timeout = if is_paused {
2225                    std::time::Duration::from_millis(10)
2226                } else {
2227                    std::time::Duration::from_millis(200)
2228                };
2229                let drain_held = active_drain
2230                    .as_ref()
2231                    .is_some_and(|drain| drain.boundary_queued);
2232                let prepare_deadline = active_drain
2233                    .as_ref()
2234                    .filter(|drain| !drain.boundary_queued)
2235                    .map(|drain| drain.prepare_deadline);
2236                let resolution_deferred = deferred_drain_command.is_some();
2237                let msg_result = tokio::select! {
2238                    biased;
2239                    _ = reader_shutdown.changed() => break,
2240                    () = async {
2241                        match prepare_deadline {
2242                            Some(deadline) => tokio::time::sleep_until(deadline).await,
2243                            None => std::future::pending().await,
2244                        }
2245                    } => {
2246                        publish_reader_fault(
2247                            &reader_fault,
2248                            &data_ready,
2249                            "Kafka source drain preparation exceeded its engine deadline",
2250                        );
2251                        return;
2252                    },
2253                    command = async {
2254                        match reader_drain_rx.as_mut() {
2255                            Some(receiver) => receiver.recv().await,
2256                            None => std::future::pending().await,
2257                        }
2258                    }, if !resolution_deferred => {
2259                        deferred_drain_command = command;
2260                        if deferred_drain_command.is_none() {
2261                            reader_drain_rx = None;
2262                        }
2263                        continue;
2264                    },
2265                    () = tokio::time::sleep(std::time::Duration::from_millis(10)), if drain_held => continue,
2266                    msg = tokio::time::timeout(recv_timeout, consumer.recv()), if !drain_held => match msg {
2267                        Ok(result) => result,
2268                        Err(_timeout) => continue,
2269                    },
2270                };
2271                match msg_result {
2272                    Ok(msg) => {
2273                        if let Some(payload) = msg.payload() {
2274                            let topic = msg.topic();
2275                            if &*cached_topic != topic {
2276                                if vnode_reassign.is_some() {
2277                                    let Some((canonical_topic, routes)) =
2278                                        vnode_partition_routes.get_key_value(topic)
2279                                    else {
2280                                        warn!(
2281                                            topic,
2282                                            "Kafka reader received a topic outside its activated vnode inventory"
2283                                        );
2284                                        publish_reader_fault(
2285                                            &reader_fault,
2286                                            &data_ready,
2287                                            "payload topic is outside the activated inventory",
2288                                        );
2289                                        return;
2290                                    };
2291                                    cached_topic = Arc::clone(canonical_topic);
2292                                    cached_topic_routes = Some(Arc::clone(routes));
2293                                } else {
2294                                    cached_topic = Arc::from(topic);
2295                                    cached_topic_routes = None;
2296                                }
2297                            }
2298                            let partition_vnode = match cached_partition_vnode(
2299                                cached_topic_routes.as_deref(),
2300                                msg.partition(),
2301                            ) {
2302                                Ok(vnode) => vnode,
2303                                Err(error) => {
2304                                    warn!(
2305                                        topic,
2306                                        partition = msg.partition(),
2307                                        %error,
2308                                        "Kafka reader rejected a payload outside its activated vnode inventory"
2309                                    );
2310                                    publish_reader_fault(
2311                                        &reader_fault,
2312                                        &data_ready,
2313                                        format!("payload route is outside the activated inventory: {error}"),
2314                                    );
2315                                    return;
2316                                }
2317                            };
2318                            let timestamp_ms = match msg.timestamp() {
2319                                rdkafka::Timestamp::CreateTime(ts)
2320                                | rdkafka::Timestamp::LogAppendTime(ts) => Some(ts),
2321                                rdkafka::Timestamp::NotAvailable => None,
2322                            };
2323                            let headers_json = if capture_headers {
2324                                use rdkafka::message::Headers;
2325                                msg.headers().and_then(|hdrs| {
2326                                    let pairs: Vec<(String, serde_json::Value)> = (0..hdrs.count())
2327                                        .map(|i| {
2328                                            let h = hdrs.get(i);
2329                                            let val = match h.value {
2330                                                Some(v) => serde_json::Value::String(
2331                                                    String::from_utf8_lossy(v).into_owned(),
2332                                                ),
2333                                                None => serde_json::Value::Null,
2334                                            };
2335                                            (h.key.to_string(), val)
2336                                        })
2337                                        .collect();
2338                                    serde_json::to_string(&pairs).ok()
2339                                })
2340                            } else {
2341                                None
2342                            };
2343                            let kp = KafkaPayload {
2344                                data: payload.to_vec(),
2345                                topic: Arc::clone(&cached_topic),
2346                                partition: msg.partition(),
2347                                partition_vnode,
2348                                offset: msg.offset(),
2349                                timestamp_ms,
2350                                headers_json,
2351                            };
2352                            let item = KafkaReaderItem::Payload(kp);
2353                            match msg_tx.try_send(item) {
2354                                Ok(()) => {
2355                                    channel_len.fetch_add(1, Ordering::Relaxed);
2356                                }
2357                                Err(crossfire::TrySendError::Full(item)) => {
2358                                    if !is_paused {
2359                                        if let Ok(assignment) = consumer.assignment() {
2360                                            let paused = consumer
2361                                                .pause(&assignment)
2362                                                .map_err(|error| {
2363                                                    format!(
2364                                                        "Kafka full-channel pause failed: {error}"
2365                                                    )
2366                                                })
2367                                                .and_then(|()| {
2368                                                    validate_kafka_partition_results(
2369                                                        "full-channel pause",
2370                                                        &assignment,
2371                                                    )
2372                                                });
2373                                            match paused {
2374                                                Ok(()) => {
2375                                                    is_paused = true;
2376                                                    debug!(
2377                                                        "reader: paused partitions (channel full)"
2378                                                    );
2379                                                }
2380                                                Err(error) => {
2381                                                    warn!(%error, "reader full-channel pause incomplete");
2382                                                }
2383                                            }
2384                                        }
2385                                    }
2386                                    channel_len.fetch_add(1, Ordering::Relaxed);
2387                                    let send_ok = tokio::select! {
2388                                        biased;
2389                                        _ = reader_shutdown.changed() => false,
2390                                        result = msg_tx.send(item) => result.is_ok(),
2391                                    };
2392                                    if !send_ok {
2393                                        channel_len.fetch_sub(1, Ordering::Relaxed);
2394                                        if !*reader_shutdown.borrow() {
2395                                            publish_reader_fault(
2396                                                &reader_fault,
2397                                                &data_ready,
2398                                                "reader output channel closed unexpectedly",
2399                                            );
2400                                        }
2401                                        break;
2402                                    }
2403                                }
2404                                Err(crossfire::TrySendError::Disconnected(_)) => {
2405                                    if !*reader_shutdown.borrow() {
2406                                        publish_reader_fault(
2407                                            &reader_fault,
2408                                            &data_ready,
2409                                            "reader output channel disconnected unexpectedly",
2410                                        );
2411                                    }
2412                                    break;
2413                                }
2414                            }
2415                            data_ready.notify_one();
2416                        }
2417                    }
2418                    Err(e) if kafka_reader_error_is_transient(&e) => {
2419                        debug!(error = %e, "Kafka consumer poll event");
2420                    }
2421                    Err(e) => {
2422                        warn!(error = %e, "Kafka consumer error");
2423                        publish_reader_fault(
2424                            &reader_fault,
2425                            &data_ready,
2426                            format!("terminal Kafka consumer error: {e}"),
2427                        );
2428                        break;
2429                    }
2430                }
2431            }
2432
2433            // Reader does not commit or unsubscribe on shutdown. `close()`
2434            // owns the connector's single final unsubscribe.
2435            // Wake a parked runtime so an unexpected exit is observed as a
2436            // disconnected channel on the next poll instead of a silent stall.
2437            data_ready.notify_one();
2438        });
2439
2440        self.msg_rx = Some(msg_rx);
2441        self.reader_handle = Some(reader_handle);
2442        self.reader_shutdown = Some(shutdown_tx);
2443        self.reader_drain_tx = reader_drain_tx;
2444    }
2445
2446    fn capture_vnode_checkpoint(
2447        &self,
2448        publication: &KafkaAssignmentPublication,
2449    ) -> Result<SourceCheckpoint, ConnectorError> {
2450        let mut checkpoint = self.offsets.to_checkpoint_for_partitions(
2451            publication
2452                .owned_partitions
2453                .iter()
2454                .filter(|(topic, partition)| {
2455                    rotation_partition_baseline(&publication.baselines, topic.as_str(), *partition)
2456                        .is_none()
2457                })
2458                .map(|(topic, partition)| (topic.as_str(), *partition)),
2459        );
2460        attach_partition_baselines(
2461            &mut checkpoint,
2462            &self.manual_partition_baselines,
2463            &publication.owned_partitions,
2464        );
2465        attach_rotation_baselines(
2466            &mut checkpoint,
2467            &publication.baselines,
2468            &publication.owned_partitions,
2469        );
2470        let assignment_version =
2471            NonZeroU64::new(publication.assignment_version).ok_or_else(|| {
2472                ConnectorError::InvalidState {
2473                    expected: "a positive vnode assignment version".into(),
2474                    actual: publication.assignment_version.to_string(),
2475                }
2476            })?;
2477        checkpoint.bind_assignment_version(assignment_version);
2478        Ok(checkpoint)
2479    }
2480
2481    fn capture_non_vnode_checkpoint(&self) -> SourceCheckpoint {
2482        if !self.manual_topic_partitions.is_empty() {
2483            let mut checkpoint = self.offsets.to_checkpoint_for_partitions(
2484                self.manual_topic_partitions
2485                    .iter()
2486                    .map(|(topic, partition)| (topic.as_str(), *partition)),
2487            );
2488            attach_partition_baselines(
2489                &mut checkpoint,
2490                &self.manual_partition_baselines,
2491                &self.manual_topic_partitions,
2492            );
2493            checkpoint.set_metadata(
2494                KAFKA_PARTITION_INVENTORY_METADATA,
2495                encode_partition_inventory(&self.manual_topic_partitions),
2496            );
2497            return checkpoint;
2498        }
2499        let assigned = lock_or_recover(&self.rebalance_state).assignment_snapshot();
2500        self.offsets.to_checkpoint_for_partitions(
2501            assigned
2502                .iter()
2503                .map(|(topic, partition)| (topic.as_str(), *partition)),
2504        )
2505    }
2506
2507    fn try_capture_checkpoint(&self) -> Result<Option<SourceCheckpoint>, ConnectorError> {
2508        self.check_reader_health("capturing a checkpoint cursor")?;
2509        let Some((registry, _)) = &self.vnode_assignment else {
2510            return Ok(Some(self.capture_non_vnode_checkpoint()));
2511        };
2512        // Cursor serialization runs outside the registry and publication locks. A final fence
2513        // check discards the candidate if ownership rotates while offsets are being encoded.
2514        try_capture_at_assignment_fence(
2515            registry,
2516            &self.reconciled_assignment_version,
2517            &self.assignment_publication,
2518            |publication| self.capture_vnode_checkpoint(publication),
2519        )
2520    }
2521}
2522
2523/// Build an offset-less `TopicPartitionList` from `(topic, partition)` refs, for
2524/// `pause`/`resume` calls.
2525fn tpl_of<'a>(parts: impl Iterator<Item = &'a (Arc<str>, i32)>) -> TopicPartitionList {
2526    let mut tpl = TopicPartitionList::new();
2527    for (topic, partition) in parts {
2528        let _ = tpl.add_partition(topic.as_ref(), *partition);
2529    }
2530    tpl
2531}
2532
2533/// Partition list for the initial `start()` assignment of a vnode-assigned source.
2534/// Owned partitions start at their checkpointed offset + 1, otherwise at
2535/// `default_offset`. Rotations rebind incrementally in the reader loop.
2536#[derive(Clone, Copy)]
2537struct VnodeResumeCursors<'a> {
2538    local_offsets: &'a OffsetTracker,
2539    handoff_offsets: &'a OffsetTracker,
2540    local_baselines: &'a KafkaPartitionBaselines,
2541    handoff_baselines: &'a KafkaPartitionBaselines,
2542}
2543
2544fn build_vnode_assignment_tpl(
2545    source_identity: &str,
2546    assignment: &[laminar_core::state::NodeId],
2547    self_id: laminar_core::state::NodeId,
2548    topic_meta: &[(Arc<str>, i32)],
2549    cursors: VnodeResumeCursors<'_>,
2550    default_offset: rdkafka::Offset,
2551) -> Result<TopicPartitionList, ConnectorError> {
2552    let mut tpl = TopicPartitionList::new();
2553    for (topic, count) in topic_meta {
2554        for partition in super::vnode_routing::owned_partitions_in_assignment(
2555            source_identity,
2556            topic.as_ref(),
2557            *count,
2558            assignment,
2559            self_id,
2560        )? {
2561            let offset = match cursors
2562                .local_offsets
2563                .get(topic.as_ref(), partition)
2564                // No local offset → handed to us in a rotation; fall back to the
2565                // previous owner's staged position, not auto.offset.reset.
2566                .or_else(|| cursors.handoff_offsets.get(topic.as_ref(), partition))
2567            {
2568                Some(offset) => {
2569                    rdkafka::Offset::Offset(offset.checked_add(1).ok_or_else(|| {
2570                        ConnectorError::ConfigurationError(format!(
2571                            "Kafka checkpoint offset overflow for '{topic}-{partition}'"
2572                        ))
2573                    })?)
2574                }
2575                None => cursors
2576                    .handoff_baselines
2577                    .get(&(topic.to_string(), partition))
2578                    .or_else(|| cursors.local_baselines.get(&(topic.to_string(), partition)))
2579                    .map_or(default_offset, |next| rdkafka::Offset::Offset(*next)),
2580            };
2581            tpl.add_partition_offset(topic.as_ref(), partition, offset)
2582                .map_err(|error| {
2583                    ConnectorError::Internal(format!(
2584                        "failed to add vnode-owned Kafka partition '{topic}-{partition}' to assignment: {error}"
2585                    ))
2586                })?;
2587        }
2588    }
2589    Ok(tpl)
2590}
2591
2592/// Resolves the numeric next-to-read position for an acquired vnode partition.
2593/// Durable handoff state always outranks a stale cursor from this node's prior
2594/// ownership stint. Guaranteed delivery never falls back to that local cursor.
2595fn acquired_numeric_position(
2596    handoff: &OffsetTracker,
2597    handoff_baselines: &KafkaPartitionBaselines,
2598    local: &OffsetTracker,
2599    local_baselines: &KafkaPartitionBaselines,
2600    topic: &str,
2601    partition: i32,
2602    allow_local: bool,
2603) -> Result<Option<i64>, ConnectorError> {
2604    if let Some(offset) = handoff.get(topic, partition) {
2605        return offset.checked_add(1).map(Some).ok_or_else(|| {
2606            ConnectorError::ConfigurationError(format!(
2607                "Kafka handoff offset overflow for '{topic}-{partition}'"
2608            ))
2609        });
2610    }
2611    if let Some(next) = handoff_baselines
2612        .get(&(topic.to_string(), partition))
2613        .copied()
2614    {
2615        return Ok(Some(next));
2616    }
2617    if allow_local {
2618        if let Some(offset) = local.get(topic, partition) {
2619            return offset.checked_add(1).map(Some).ok_or_else(|| {
2620                ConnectorError::ConfigurationError(format!(
2621                    "Kafka local offset overflow for '{topic}-{partition}'"
2622                ))
2623            });
2624        }
2625        if let Some(next) = local_baselines.get(&(topic.to_string(), partition)) {
2626            return Ok(Some(*next));
2627        }
2628    }
2629    Ok(None)
2630}
2631
2632/// Builds the seek applied after a group assignment. With a deterministic
2633/// fallback this includes *every* assigned partition; otherwise it contains
2634/// only checkpointed partitions and leaves Kafka's configured group behavior
2635/// intact (best-effort mode).
2636fn assignment_seek_tpl(
2637    offsets: &OffsetTracker,
2638    assigned: &[(String, i32)],
2639    baselines: Option<&KafkaPartitionBaselines>,
2640    deterministic_fallback: Option<rdkafka::Offset>,
2641    require_all: bool,
2642) -> Result<TopicPartitionList, ConnectorError> {
2643    let mut tpl = TopicPartitionList::new();
2644    for (topic, partition) in assigned {
2645        let position = match offsets.get(topic, *partition) {
2646            Some(offset) => rdkafka::Offset::Offset(offset.checked_add(1).ok_or_else(|| {
2647                ConnectorError::ConfigurationError(format!(
2648                    "Kafka checkpoint offset overflow for '{topic}-{partition}'"
2649                ))
2650            })?),
2651            None => {
2652                if let Some(next) =
2653                    baselines.and_then(|positions| positions.get(&(topic.clone(), *partition)))
2654                {
2655                    rdkafka::Offset::Offset(*next)
2656                } else if let Some(offset) = deterministic_fallback {
2657                    offset
2658                } else if require_all {
2659                    return Err(ConnectorError::ConfigurationError(format!(
2660                        "Kafka partition '{topic}-{partition}' has no durable next-to-read baseline"
2661                    )));
2662                } else {
2663                    continue;
2664                }
2665            }
2666        };
2667        tpl.add_partition_offset(topic, *partition, position)
2668            .map_err(|e| {
2669                ConnectorError::Internal(format!(
2670                    "failed to build Kafka assignment seek for '{topic}-{partition}': {e}"
2671                ))
2672            })?;
2673    }
2674    Ok(tpl)
2675}
2676
2677/// rdkafka start position for a partition that has no checkpointed offset under
2678/// engine-controlled assignment, derived from the configured startup mode.
2679fn startup_default_offset(mode: &StartupMode) -> rdkafka::Offset {
2680    match mode {
2681        StartupMode::Earliest => rdkafka::Offset::Beginning,
2682        StartupMode::Latest => rdkafka::Offset::End,
2683        // GroupOffsets resumes from committed offsets (falling back to
2684        // `auto.offset.reset`); Specific/Timestamp aren't combined with vnode
2685        // assignment, so they also defer to the stored position.
2686        _ => rdkafka::Offset::Stored,
2687    }
2688}
2689
2690/// Deterministic position for a partition absent from engine state. Group
2691/// commits are deliberately excluded because they can belong to an abandoned
2692/// engine timeline. Specific/timestamp starts are assigned explicitly by
2693/// `start()` and therefore have no single partition-independent fallback.
2694fn deterministic_initial_offset(mode: &StartupMode, reset: OffsetReset) -> Option<rdkafka::Offset> {
2695    match (mode, reset) {
2696        (StartupMode::Latest, _) | (StartupMode::GroupOffsets, OffsetReset::Latest) => {
2697            Some(rdkafka::Offset::End)
2698        }
2699        (StartupMode::GroupOffsets, OffsetReset::None)
2700        | (StartupMode::SpecificOffsets(_) | StartupMode::Timestamp(_), _) => None,
2701        (StartupMode::GroupOffsets, OffsetReset::Earliest) | (StartupMode::Earliest, _) => {
2702            Some(rdkafka::Offset::Beginning)
2703        }
2704    }
2705}
2706
2707fn encode_partition_inventory(inventory: &KafkaPartitionSet) -> String {
2708    let mut canonical: Vec<_> = inventory.iter().cloned().collect();
2709    canonical.sort_unstable();
2710    serde_json::to_string(&canonical).expect("Kafka partition inventory is serializable")
2711}
2712
2713fn decode_partition_inventory(
2714    checkpoint: &SourceCheckpoint,
2715) -> Result<Option<KafkaPartitionSet>, ConnectorError> {
2716    let Some(encoded) = checkpoint.get_metadata(KAFKA_PARTITION_INVENTORY_METADATA) else {
2717        return Ok(None);
2718    };
2719    let canonical: Vec<(String, i32)> = serde_json::from_str(encoded).map_err(|error| {
2720        ConnectorError::ConfigurationError(format!(
2721            "invalid Kafka checkpoint partition inventory: {error}"
2722        ))
2723    })?;
2724    if canonical
2725        .iter()
2726        .any(|(topic, partition)| topic.is_empty() || *partition < 0)
2727        || canonical.windows(2).any(|pair| pair[0] >= pair[1])
2728    {
2729        return Err(ConnectorError::ConfigurationError(
2730            "invalid Kafka checkpoint partition inventory: entries must be canonical, unique, and non-negative"
2731                .into(),
2732        ));
2733    }
2734    Ok(Some(canonical.into_iter().collect()))
2735}
2736
2737fn validate_resume_inventory(
2738    checkpoint: Option<&KafkaPartitionSet>,
2739    current: &KafkaPartitionSet,
2740) -> Result<(), ConnectorError> {
2741    let checkpoint = checkpoint.ok_or_else(|| {
2742        ConnectorError::ConfigurationError(
2743            "Kafka engine-owned resume checkpoint has no partition inventory".into(),
2744        )
2745    })?;
2746    if checkpoint != current {
2747        return Err(ConnectorError::ConfigurationError(format!(
2748            "Kafka partition inventory changed across recovery: checkpoint={checkpoint:?}, current={current:?}"
2749        )));
2750    }
2751    Ok(())
2752}
2753
2754fn partition_baseline_key(topic: &str, partition: i32) -> String {
2755    format!("{KAFKA_PARTITION_BASELINE_PREFIX}{topic}:{partition}")
2756}
2757
2758fn decode_partition_baselines_from_offsets(
2759    offsets: &std::collections::HashMap<String, String>,
2760) -> Result<KafkaPartitionBaselines, ConnectorError> {
2761    let mut baselines = KafkaPartitionBaselines::new();
2762    for (key, value) in offsets {
2763        let Some(encoded) = key.strip_prefix(KAFKA_PARTITION_BASELINE_PREFIX) else {
2764            continue;
2765        };
2766        let (topic, partition_text) = encoded.rsplit_once(':').ok_or_else(|| {
2767            ConnectorError::ConfigurationError(format!(
2768                "invalid Kafka partition baseline key '{key}'"
2769            ))
2770        })?;
2771        let partition = partition_text.parse::<i32>().map_err(|_| {
2772            ConnectorError::ConfigurationError(format!(
2773                "invalid Kafka partition baseline key '{key}'"
2774            ))
2775        })?;
2776        let next = value.parse::<i64>().map_err(|_| {
2777            ConnectorError::ConfigurationError(format!(
2778                "invalid Kafka next-to-read baseline for '{topic}-{partition_text}': '{value}'"
2779            ))
2780        })?;
2781        if topic.is_empty()
2782            || topic.contains(':')
2783            || partition < 0
2784            || partition.to_string() != partition_text
2785            || next < 0
2786            || next == i64::MAX
2787            || next.to_string() != value.as_str()
2788        {
2789            return Err(ConnectorError::ConfigurationError(format!(
2790                "invalid Kafka partition baseline '{key}' = '{value}'"
2791            )));
2792        }
2793        if baselines
2794            .insert((topic.to_string(), partition), next)
2795            .is_some()
2796        {
2797            return Err(ConnectorError::ConfigurationError(format!(
2798                "duplicate Kafka partition baseline for '{topic}-{partition}'"
2799            )));
2800        }
2801    }
2802    Ok(baselines)
2803}
2804
2805fn decode_partition_baselines(
2806    checkpoint: &SourceCheckpoint,
2807) -> Result<KafkaPartitionBaselines, ConnectorError> {
2808    decode_partition_baselines_from_offsets(checkpoint.offsets())
2809}
2810
2811fn attach_partition_baselines(
2812    checkpoint: &mut SourceCheckpoint,
2813    baselines: &KafkaPartitionBaselines,
2814    included: &KafkaPartitionSet,
2815) {
2816    for ((topic, partition), next) in baselines {
2817        if included.contains(&(topic.clone(), *partition)) {
2818            checkpoint.set_offset(partition_baseline_key(topic, *partition), next.to_string());
2819        }
2820    }
2821}
2822
2823fn rotation_partition_baseline(
2824    baselines: &KafkaRotationBaselines,
2825    topic: &str,
2826    partition: i32,
2827) -> Option<i64> {
2828    baselines
2829        .get(topic)
2830        .and_then(|partitions| partitions.get(&partition))
2831        .copied()
2832}
2833
2834fn rotation_baselines_len(baselines: &KafkaRotationBaselines) -> usize {
2835    baselines.values().map(std::collections::HashMap::len).sum()
2836}
2837
2838fn update_rotation_baselines(
2839    current: &KafkaRotationBaselines,
2840    owned: &KafkaPartitionSet,
2841    acquired: &KafkaPartitionBaselines,
2842) -> KafkaRotationBaselines {
2843    let mut updated = KafkaRotationBaselines::new();
2844    for (topic, partition) in owned {
2845        if let Some(next) = rotation_partition_baseline(current, topic, *partition) {
2846            updated
2847                .entry(Arc::from(topic.as_str()))
2848                .or_default()
2849                .insert(*partition, next);
2850        }
2851    }
2852    for ((topic, partition), next) in acquired {
2853        updated
2854            .entry(Arc::from(topic.as_str()))
2855            .or_default()
2856            .insert(*partition, *next);
2857    }
2858    updated
2859}
2860
2861fn attach_rotation_baselines(
2862    checkpoint: &mut SourceCheckpoint,
2863    baselines: &KafkaRotationBaselines,
2864    included: &KafkaPartitionSet,
2865) {
2866    for (topic, partition) in included {
2867        if let Some(next) = rotation_partition_baseline(baselines, topic, *partition) {
2868            checkpoint.set_offset(partition_baseline_key(topic, *partition), next.to_string());
2869        }
2870    }
2871}
2872
2873fn vnode_payload_is_current(
2874    ownership: Option<(&[laminar_core::state::NodeId], laminar_core::state::NodeId)>,
2875    partition_vnode: Option<u32>,
2876    required_next: Option<i64>,
2877    offset: i64,
2878) -> Result<bool, ConnectorError> {
2879    let owned = if let Some((assignment, self_id)) = ownership {
2880        let vnode = partition_vnode.ok_or_else(|| {
2881            ConnectorError::ConfigurationError(
2882                "Kafka payload has no canonical source/topic/partition vnode route".into(),
2883            )
2884        })?;
2885        let vnode_index = usize::try_from(vnode).map_err(|_| {
2886            ConnectorError::ConfigurationError(
2887                "Kafka vnode id cannot be represented on this platform".into(),
2888            )
2889        })?;
2890        let owner = assignment.get(vnode_index).ok_or_else(|| {
2891            ConnectorError::ConfigurationError(format!(
2892                "Kafka cached vnode {vnode} is outside owner map cardinality {}",
2893                assignment.len()
2894            ))
2895        })?;
2896        *owner == self_id
2897    } else {
2898        true
2899    };
2900    Ok(owned && required_next.is_none_or(|next| offset >= next))
2901}
2902
2903fn retire_accepted_rotation_baselines(
2904    baselines: &mut KafkaRotationBaselines,
2905    accepted_offsets: &[(Arc<str>, i32, i64)],
2906) {
2907    let mut accepted = std::collections::HashMap::<(&str, i32), i64>::new();
2908    for (topic, partition, offset) in accepted_offsets {
2909        accepted
2910            .entry((topic.as_ref(), *partition))
2911            .and_modify(|current| *current = (*current).max(*offset))
2912            .or_insert(*offset);
2913    }
2914    baselines.retain(|topic, partitions| {
2915        partitions.retain(|partition, next| {
2916            accepted
2917                .get(&(topic.as_ref(), *partition))
2918                .is_none_or(|offset| offset < next)
2919        });
2920        !partitions.is_empty()
2921    });
2922}
2923
2924fn validate_partition_baselines(
2925    baselines: &KafkaPartitionBaselines,
2926    inventory: &KafkaPartitionSet,
2927) -> Result<(), ConnectorError> {
2928    let baseline_inventory: KafkaPartitionSet = baselines.keys().cloned().collect();
2929    if baseline_inventory != *inventory {
2930        return Err(ConnectorError::ConfigurationError(format!(
2931            "Kafka guaranteed recovery baseline inventory does not match its partition cut: baselines={baseline_inventory:?}, partitions={inventory:?}"
2932        )));
2933    }
2934    Ok(())
2935}
2936
2937fn validate_positions_not_expired(
2938    offsets: &OffsetTracker,
2939    baselines: &KafkaPartitionBaselines,
2940    low_watermarks: &KafkaPartitionBaselines,
2941    inventory: &KafkaPartitionSet,
2942) -> Result<(), ConnectorError> {
2943    for (topic, partition) in inventory {
2944        let desired = match offsets.get(topic, *partition) {
2945            Some(last) => last.checked_add(1).ok_or_else(|| {
2946                ConnectorError::ConfigurationError(format!(
2947                    "Kafka checkpoint offset overflow for '{topic}-{partition}'"
2948                ))
2949            })?,
2950            None => *baselines.get(&(topic.clone(), *partition)).ok_or_else(|| {
2951                ConnectorError::ConfigurationError(format!(
2952                    "Kafka partition '{topic}-{partition}' has no durable next-to-read baseline"
2953                ))
2954            })?,
2955        };
2956        let low = *low_watermarks
2957            .get(&(topic.clone(), *partition))
2958            .ok_or_else(|| {
2959                ConnectorError::ConnectionFailed(format!(
2960                    "Kafka watermark response omitted partition '{topic}-{partition}'"
2961                ))
2962            })?;
2963        if desired < low {
2964            return Err(ConnectorError::ConfigurationError(format!(
2965                "Kafka retention advanced partition '{topic}-{partition}' to {low} past the durable next-to-read position {desired}"
2966            )));
2967        }
2968    }
2969    Ok(())
2970}
2971
2972fn kafka_reader_error_is_transient(error: &KafkaError) -> bool {
2973    use rdkafka::types::RDKafkaErrorCode;
2974
2975    let code = match error {
2976        KafkaError::PartitionEOF(_) | KafkaError::NoMessageReceived => return true,
2977        KafkaError::MessageConsumption(code) | KafkaError::Global(code) => *code,
2978        _ => return false,
2979    };
2980
2981    matches!(
2982        code,
2983        RDKafkaErrorCode::BrokerDestroy
2984            | RDKafkaErrorCode::BrokerTransportFailure
2985            | RDKafkaErrorCode::Resolve
2986            | RDKafkaErrorCode::AllBrokersDown
2987            | RDKafkaErrorCode::OperationTimedOut
2988            | RDKafkaErrorCode::QueueFull
2989            | RDKafkaErrorCode::NodeUpdate
2990            | RDKafkaErrorCode::WaitingForCoordinator
2991            | RDKafkaErrorCode::UnknownGroup
2992            | RDKafkaErrorCode::InProgress
2993            | RDKafkaErrorCode::PreviousInProgress
2994            | RDKafkaErrorCode::TimedOutQueue
2995            | RDKafkaErrorCode::WaitCache
2996            | RDKafkaErrorCode::Interrupted
2997            | RDKafkaErrorCode::Partial
2998            | RDKafkaErrorCode::Retry
2999            | RDKafkaErrorCode::PollExceeded
3000            | RDKafkaErrorCode::UnknownBroker
3001            | RDKafkaErrorCode::AssignmentLost
3002            | RDKafkaErrorCode::DestroyBroker
3003            | RDKafkaErrorCode::UnknownTopicOrPartition
3004            | RDKafkaErrorCode::LeaderNotAvailable
3005            | RDKafkaErrorCode::NotLeaderForPartition
3006            | RDKafkaErrorCode::RequestTimedOut
3007            | RDKafkaErrorCode::BrokerNotAvailable
3008            | RDKafkaErrorCode::ReplicaNotAvailable
3009            | RDKafkaErrorCode::NetworkException
3010            | RDKafkaErrorCode::CoordinatorLoadInProgress
3011            | RDKafkaErrorCode::CoordinatorNotAvailable
3012            | RDKafkaErrorCode::NotCoordinator
3013            | RDKafkaErrorCode::IllegalGeneration
3014            | RDKafkaErrorCode::UnknownMemberId
3015            | RDKafkaErrorCode::RebalanceInProgress
3016            | RDKafkaErrorCode::NotController
3017            | RDKafkaErrorCode::KafkaStorageError
3018            | RDKafkaErrorCode::ReassignmentInProgress
3019            | RDKafkaErrorCode::FetchSessionIdNotFound
3020            | RDKafkaErrorCode::InvalidFetchSessionEpoch
3021            | RDKafkaErrorCode::FencedLeaderEpoch
3022            | RDKafkaErrorCode::UnknownLeaderEpoch
3023            | RDKafkaErrorCode::StaleBrokerEpoch
3024            | RDKafkaErrorCode::OffsetNotAvailable
3025            | RDKafkaErrorCode::MemberIdRequired
3026            | RDKafkaErrorCode::PreferredLeaderNotAvailable
3027            | RDKafkaErrorCode::EligibleLeadersNotAvailable
3028            | RDKafkaErrorCode::UnstableOffsetCommit
3029            | RDKafkaErrorCode::ThrottlingQuotaExceeded
3030            | RDKafkaErrorCode::UnknownTopicId
3031    )
3032}
3033
3034fn consumer_creation_error(error: &KafkaError) -> ConnectorError {
3035    ConnectorError::ConfigurationError(format!(
3036        "failed to create Kafka consumer from local configuration: {error}"
3037    ))
3038}
3039
3040async fn fetch_explicit_topic_metadata(
3041    blocking_tasks: KafkaBlockingTasks,
3042    consumer: Arc<StreamConsumer<LaminarConsumerContext>>,
3043    topics: Vec<String>,
3044) -> Result<Vec<(Arc<str>, i32)>, ConnectorError> {
3045    const METADATA_BUDGET: std::time::Duration = std::time::Duration::from_secs(10);
3046    let deadline = tokio::time::Instant::now() + METADATA_BUDGET;
3047    let task = blocking_tasks.run(move || {
3048        let blocking_deadline = std::time::Instant::now() + METADATA_BUDGET;
3049        let mut topic_meta = Vec::with_capacity(topics.len());
3050        for topic in topics {
3051            let remaining = blocking_deadline.saturating_duration_since(std::time::Instant::now());
3052            if remaining.is_zero() {
3053                return Err(ConnectorError::Timeout(
3054                    u64::try_from(METADATA_BUDGET.as_millis()).unwrap_or(u64::MAX),
3055                ));
3056            }
3057            let metadata = consumer
3058                .fetch_metadata(Some(topic.as_str()), remaining)
3059                .map_err(|error| fetch_error(&topic, &error))?;
3060            let topic_metadata = metadata
3061                .topics()
3062                .iter()
3063                .find(|candidate| candidate.name() == topic.as_str())
3064                .ok_or_else(|| invalid_response(&topic, "metadata response omitted the topic"))?;
3065            if let Some(error) = topic_metadata.error() {
3066                return Err(topic_error(&topic, error.into()));
3067            }
3068            let partition_count = topic_metadata.partitions().len();
3069            if partition_count == 0 {
3070                return Err(invalid_response(&topic, "broker returned no partitions"));
3071            }
3072            topic_meta.push((
3073                Arc::from(topic.as_str()),
3074                i32::try_from(partition_count).unwrap_or(i32::MAX),
3075            ));
3076        }
3077        Ok(topic_meta)
3078    });
3079    match tokio::time::timeout_at(deadline, task).await {
3080        Ok(Ok(result)) => result,
3081        Ok(Err(error)) => Err(ConnectorError::Internal(format!(
3082            "Kafka metadata worker failed: {error}"
3083        ))),
3084        Err(_) => Err(ConnectorError::Timeout(
3085            u64::try_from(METADATA_BUDGET.as_millis()).unwrap_or(u64::MAX),
3086        )),
3087    }
3088}
3089
3090async fn fetch_partition_low_watermarks(
3091    blocking_tasks: KafkaBlockingTasks,
3092    consumer: Arc<StreamConsumer<LaminarConsumerContext>>,
3093    partitions: &KafkaPartitionSet,
3094) -> Result<KafkaPartitionBaselines, ConnectorError> {
3095    let deadline = tokio::time::Instant::now() + KAFKA_POSITION_LOOKUP_BUDGET;
3096    let mut remaining = partitions.iter().cloned();
3097    let mut jobs = tokio::task::JoinSet::new();
3098    let mut baselines = KafkaPartitionBaselines::with_capacity(partitions.len());
3099
3100    loop {
3101        while jobs.len() < KAFKA_POSITION_LOOKUP_CONCURRENCY {
3102            let Some((topic, partition)) = remaining.next() else {
3103                break;
3104            };
3105            jobs.spawn(fetch_partition_low_watermark(
3106                blocking_tasks.clone(),
3107                Arc::clone(&consumer),
3108                topic,
3109                partition,
3110                deadline,
3111            ));
3112        }
3113        let Some(result) = jobs.join_next().await else {
3114            break;
3115        };
3116        let ((topic, partition), low) = result.map_err(|error| {
3117            ConnectorError::Internal(format!("Kafka watermark worker failed: {error}"))
3118        })??;
3119        baselines.insert((topic, partition), low);
3120    }
3121
3122    Ok(baselines)
3123}
3124
3125async fn fetch_partition_low_watermark(
3126    blocking_tasks: KafkaBlockingTasks,
3127    consumer: Arc<StreamConsumer<LaminarConsumerContext>>,
3128    topic: String,
3129    partition: i32,
3130    deadline: tokio::time::Instant,
3131) -> Result<((String, i32), i64), ConnectorError> {
3132    let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
3133    if remaining.is_zero() {
3134        return Err(ConnectorError::Timeout(
3135            u64::try_from(KAFKA_POSITION_LOOKUP_BUDGET.as_millis()).unwrap_or(u64::MAX),
3136        ));
3137    }
3138    let task_topic = topic.clone();
3139    let task = blocking_tasks.run(move || {
3140        consumer
3141            .fetch_watermarks(&task_topic, partition, remaining)
3142            .map_err(|error| {
3143                ConnectorError::ConnectionFailed(format!(
3144                    "failed to fetch Kafka watermarks for '{task_topic}-{partition}': {error}"
3145                ))
3146            })
3147    });
3148    match tokio::time::timeout_at(deadline, task).await {
3149        Ok(Ok(Ok((low, _high)))) if (0..i64::MAX).contains(&low) => Ok(((topic, partition), low)),
3150        Ok(Ok(Ok((low, _)))) => Err(ConnectorError::ConnectionFailed(format!(
3151            "Kafka returned invalid low watermark {low} for '{topic}-{partition}'"
3152        ))),
3153        Ok(Ok(Err(error))) => Err(error),
3154        Ok(Err(error)) => Err(ConnectorError::Internal(format!(
3155            "Kafka watermark lookup task failed: {error}"
3156        ))),
3157        Err(_) => Err(ConnectorError::Timeout(
3158            u64::try_from(KAFKA_POSITION_LOOKUP_BUDGET.as_millis()).unwrap_or(u64::MAX),
3159        )),
3160    }
3161}
3162
3163async fn resolve_timestamp_offsets(
3164    blocking_tasks: KafkaBlockingTasks,
3165    consumer: Arc<StreamConsumer<LaminarConsumerContext>>,
3166    requested: TopicPartitionList,
3167) -> Result<TopicPartitionList, ConnectorError> {
3168    const LOOKUP_BUDGET: std::time::Duration = std::time::Duration::from_secs(10);
3169    let deadline = tokio::time::Instant::now() + LOOKUP_BUDGET;
3170    let task = blocking_tasks.run(move || {
3171        consumer
3172            .offsets_for_times(requested, LOOKUP_BUDGET)
3173            .map_err(|error| {
3174                ConnectorError::ConnectionFailed(format!(
3175                    "failed to resolve Kafka timestamp offsets: {error}"
3176                ))
3177            })
3178    });
3179    match tokio::time::timeout_at(deadline, task).await {
3180        Ok(Ok(result)) => result,
3181        Ok(Err(error)) => Err(ConnectorError::Internal(format!(
3182            "Kafka timestamp worker failed: {error}"
3183        ))),
3184        Err(_) => Err(ConnectorError::Timeout(
3185            u64::try_from(LOOKUP_BUDGET.as_millis()).unwrap_or(u64::MAX),
3186        )),
3187    }
3188}
3189
3190impl KafkaSource {
3191    async fn decode_polled_payloads(
3192        &mut self,
3193    ) -> Result<(arrow_array::RecordBatch, Option<Vec<usize>>), ConnectorError> {
3194        // Resolve Avro schemas from Schema Registry before deserialization.
3195        // Also detect schema evolution when new schema IDs appear.
3196        if let Some(avro_deser) = self
3197            .deserializer
3198            .as_any_mut()
3199            .and_then(|any| any.downcast_mut::<AvroDeserializer>())
3200        {
3201            let mut new_schema_ids = Vec::new();
3202            for &(start, len) in &self.poll_payload_offsets {
3203                if let Some(schema_id) = AvroDeserializer::extract_confluent_id(
3204                    &self.poll_payload_buf[start..start + len],
3205                ) {
3206                    let is_new = avro_deser
3207                        .ensure_schema_registered(schema_id)
3208                        .await
3209                        .map_err(|error| {
3210                            terminalize_guaranteed_poll_error(
3211                                self.delivery,
3212                                &mut self.state,
3213                                &self.metrics,
3214                                self.reader_shutdown.as_ref(),
3215                                error,
3216                            )
3217                        })?;
3218                    if is_new {
3219                        new_schema_ids.push(schema_id);
3220                    }
3221                }
3222            }
3223
3224            // Detect schema evolution by diffing successive writer schemas.
3225            if !new_schema_ids.is_empty()
3226                && self.config.schema_evolution_strategy != SchemaEvolutionStrategy::Ignore
3227            {
3228                if let Some(ref sr) = self.schema_registry {
3229                    let compat = self
3230                        .config
3231                        .schema_compatibility
3232                        .map_or(CompatibilityMode::Backward, CompatibilityMode::from);
3233                    let evolver = SchemaEvolution::new(compat);
3234
3235                    for id in new_schema_ids {
3236                        let cached = sr.resolve_confluent_id(id).await.map_err(|error| {
3237                            terminalize_guaranteed_poll_error(
3238                                self.delivery,
3239                                &mut self.state,
3240                                &self.metrics,
3241                                self.reader_shutdown.as_ref(),
3242                                error,
3243                            )
3244                        })?;
3245
3246                        let Some(ref prev) = self.last_avro_schema else {
3247                            // First schema — establish baseline, nothing to diff.
3248                            info!(schema_id = id, "initial Avro schema registered");
3249                            self.last_avro_schema = Some(Arc::clone(&cached.arrow_schema));
3250                            continue;
3251                        };
3252
3253                        let changes = evolver.diff_schemas(prev, &cached.arrow_schema);
3254                        self.last_avro_schema = Some(Arc::clone(&cached.arrow_schema));
3255
3256                        if changes.is_empty() {
3257                            info!(
3258                                schema_id = id,
3259                                "new Avro schema ID registered, no field changes"
3260                            );
3261                            continue;
3262                        }
3263                        let verdict = evolver.evaluate_evolution(&changes);
3264                        match &verdict {
3265                            EvolutionVerdict::Compatible => {
3266                                info!(schema_id = id, ?changes, "schema evolved (compatible)");
3267                            }
3268                            EvolutionVerdict::RequiresMigration => {
3269                                warn!(
3270                                    schema_id = id,
3271                                    ?changes,
3272                                    "schema evolved (requires migration)"
3273                                );
3274                            }
3275                            EvolutionVerdict::Incompatible(reason) => {
3276                                if self.config.schema_evolution_strategy
3277                                    == SchemaEvolutionStrategy::Reject
3278                                {
3279                                    let error = ConnectorError::SchemaMismatch(format!(
3280                                        "incompatible schema evolution for ID {id}: {reason}"
3281                                    ));
3282                                    return Err(terminalize_guaranteed_poll_error(
3283                                        self.delivery,
3284                                        &mut self.state,
3285                                        &self.metrics,
3286                                        self.reader_shutdown.as_ref(),
3287                                        error,
3288                                    ));
3289                                }
3290                                warn!(
3291                                    schema_id = id, %reason, ?changes,
3292                                    "incompatible schema evolution detected"
3293                                );
3294                            }
3295                        }
3296                    }
3297                }
3298            }
3299        }
3300
3301        let refs: Vec<&[u8]> = self
3302            .poll_payload_offsets
3303            .iter()
3304            .map(|&(start, len)| &self.poll_payload_buf[start..start + len])
3305            .collect();
3306
3307        // Try batch deserialization first (fast path). If it fails, fall back
3308        // to per-record deserialization to isolate poison pills.
3309        let (batch, good_indices) = match self.deserializer.deserialize_batch(&refs, &self.schema) {
3310            Ok(batch) => (batch, None),
3311            Err(batch_err) if self.delivery != DeliveryGuarantee::BestEffort => {
3312                // Without a checkpoint-coupled dead-letter path, skipping even one input would
3313                // let a later checkpoint seal a cursor beyond data that was never emitted. Stop
3314                // this connector generation so recovery must restart from its durable cursor.
3315                return Err(terminalize_guaranteed_poll_error(
3316                    self.delivery,
3317                    &mut self.state,
3318                    &self.metrics,
3319                    self.reader_shutdown.as_ref(),
3320                    ConnectorError::Serde(batch_err),
3321                ));
3322            }
3323            Err(batch_err) => {
3324                // Best-effort-only fallback: deserialize one at a time, collect successful
3325                // batches directly (avoids double-deserialization).
3326                // Track indices of successful records so metadata vectors can
3327                // be filtered to match the reduced row count.
3328                let mut good_batches = Vec::with_capacity(refs.len());
3329                let mut good_idx = Vec::with_capacity(refs.len());
3330                let mut error_count = 0usize;
3331                for (i, r) in refs.iter().enumerate() {
3332                    match self
3333                        .deserializer
3334                        .deserialize_batch(std::slice::from_ref(r), &self.schema)
3335                    {
3336                        Ok(batch) => {
3337                            good_batches.push(batch);
3338                            good_idx.push(i);
3339                        }
3340                        Err(e) => {
3341                            error_count += 1;
3342                            self.metrics.record_error();
3343                            warn!(error = %e, "skipping poison pill record");
3344                        }
3345                    }
3346                }
3347                if good_batches.is_empty() {
3348                    return Err(ConnectorError::Serde(batch_err));
3349                }
3350                // Escalate if the error rate exceeds the configured threshold.
3351                if error_count > 0 {
3352                    let error_count = u32::try_from(error_count).map_err(|_| {
3353                        ConnectorError::Internal(
3354                            "Kafka deserialization error count exceeds u32".into(),
3355                        )
3356                    })?;
3357                    let record_count = u32::try_from(refs.len()).map_err(|_| {
3358                        ConnectorError::Internal("Kafka batch record count exceeds u32".into())
3359                    })?;
3360                    let error_rate = f64::from(error_count) / f64::from(record_count);
3361                    if error_rate > self.config.max_deser_error_rate {
3362                        return Err(ConnectorError::Serde(batch_err));
3363                    }
3364                    warn!(
3365                        skipped = error_count,
3366                        total = refs.len(),
3367                        error_rate = %format_args!("{error_rate:.1}"),
3368                        "deserialized batch with poison pill isolation"
3369                    );
3370                }
3371                let concat_schema = good_batches[0].schema();
3372                let batch = arrow_select::concat::concat_batches(&concat_schema, &good_batches)
3373                    .map_err(|e| {
3374                        ConnectorError::Internal(format!("failed to concat batches: {e}"))
3375                    })?;
3376                (batch, Some(good_idx))
3377            }
3378        };
3379
3380        // Kafka source formats map one broker message to one row. A short successful decode is a
3381        // silent drop unless it is rejected before the message offsets become checkpointable.
3382        let expected_rows = good_indices.as_ref().map_or(refs.len(), Vec::len);
3383        if batch.num_rows() != expected_rows {
3384            let error = ConnectorError::Serde(SerdeError::RecordCountMismatch {
3385                expected: expected_rows,
3386                got: batch.num_rows(),
3387            });
3388            return Err(terminalize_guaranteed_poll_error(
3389                self.delivery,
3390                &mut self.state,
3391                &self.metrics,
3392                self.reader_shutdown.as_ref(),
3393                error,
3394            ));
3395        }
3396
3397        Ok((batch, good_indices))
3398    }
3399}
3400
3401impl KafkaSource {
3402    fn append_metadata_columns(
3403        &mut self,
3404        batch: arrow_array::RecordBatch,
3405        good_indices: Option<&[usize]>,
3406        include_metadata: bool,
3407        include_headers: bool,
3408    ) -> Result<arrow_array::RecordBatch, ConnectorError> {
3409        // If poison pill fallback filtered records, also filter metadata
3410        // vectors so their lengths match the deserialized batch row count.
3411        if let Some(idx) = good_indices {
3412            if include_metadata {
3413                self.poll_meta_partitions =
3414                    idx.iter().map(|&i| self.poll_meta_partitions[i]).collect();
3415                self.poll_meta_offsets = idx.iter().map(|&i| self.poll_meta_offsets[i]).collect();
3416                self.poll_meta_timestamps =
3417                    idx.iter().map(|&i| self.poll_meta_timestamps[i]).collect();
3418            }
3419            if include_headers {
3420                self.poll_meta_headers = idx
3421                    .iter()
3422                    .map(|&i| std::mem::take(&mut self.poll_meta_headers[i]))
3423                    .collect();
3424            }
3425        }
3426
3427        // Append metadata columns if configured.
3428        let needs_meta = include_metadata && !self.poll_meta_partitions.is_empty();
3429        let needs_headers = include_headers && !self.poll_meta_headers.is_empty();
3430        let batch = if needs_meta || needs_headers {
3431            use arrow_schema::{DataType, Field};
3432
3433            let mut fields = batch.schema().fields().to_vec();
3434            let mut columns: Vec<Arc<dyn arrow_array::Array>> = batch.columns().to_vec();
3435
3436            if needs_meta {
3437                use arrow_array::{Int32Array, Int64Array, TimestampMillisecondArray};
3438                use arrow_schema::TimeUnit;
3439                fields.push(Arc::new(Field::new("_partition", DataType::Int32, false)));
3440                columns.push(Arc::new(Int32Array::from(std::mem::take(
3441                    &mut self.poll_meta_partitions,
3442                ))));
3443                fields.push(Arc::new(Field::new("_offset", DataType::Int64, false)));
3444                columns.push(Arc::new(Int64Array::from(std::mem::take(
3445                    &mut self.poll_meta_offsets,
3446                ))));
3447                fields.push(Arc::new(Field::new(
3448                    "_timestamp",
3449                    DataType::Timestamp(TimeUnit::Millisecond, None),
3450                    true,
3451                )));
3452                columns.push(Arc::new(TimestampMillisecondArray::from(std::mem::take(
3453                    &mut self.poll_meta_timestamps,
3454                ))));
3455            }
3456            if needs_headers {
3457                fields.push(Arc::new(Field::new("_headers", DataType::Utf8, true)));
3458                columns.push(Arc::new(arrow_array::StringArray::from(std::mem::take(
3459                    &mut self.poll_meta_headers,
3460                ))));
3461            }
3462
3463            let meta_schema = Arc::new(arrow_schema::Schema::new(fields));
3464            arrow_array::RecordBatch::try_new(meta_schema, columns).map_err(|e| {
3465                terminalize_guaranteed_poll_error(
3466                    self.delivery,
3467                    &mut self.state,
3468                    &self.metrics,
3469                    self.reader_shutdown.as_ref(),
3470                    ConnectorError::Internal(format!("failed to append metadata columns: {e}")),
3471                )
3472            })?
3473        } else {
3474            batch
3475        };
3476
3477        Ok(batch)
3478    }
3479}
3480impl KafkaSource {
3481    fn prepare_start(&mut self, request: SourceStart) -> Result<KafkaStartPlan, ConnectorError> {
3482        let (config, position, delivery) = request.into_parts();
3483
3484        // Resolve and validate the complete cursor policy before creating a
3485        // consumer. `StreamConsumer` construction starts librdkafka background
3486        // activity, so no malformed durable position may reach that boundary.
3487        let kafka_config = if config.properties().is_empty() {
3488            self.config.clone()
3489        } else {
3490            KafkaSourceConfig::from_config(&config)?
3491        };
3492        let (installed_offsets, resume_attempt, is_resume, resume_inventory, resume_baselines) =
3493            match position {
3494                SourcePosition::Initial => (
3495                    OffsetTracker::new(),
3496                    None,
3497                    false,
3498                    None,
3499                    KafkaPartitionBaselines::new(),
3500                ),
3501                SourcePosition::Resume {
3502                    attempt,
3503                    checkpoint,
3504                } => {
3505                    let inventory = decode_partition_inventory(&checkpoint)?;
3506                    let baselines = decode_partition_baselines(&checkpoint)?;
3507                    (
3508                        OffsetTracker::try_from_checkpoint(&checkpoint)?,
3509                        Some(attempt),
3510                        true,
3511                        inventory,
3512                        baselines,
3513                    )
3514                }
3515            };
3516        if (self.vnode_assignment.is_some() || delivery != DeliveryGuarantee::BestEffort)
3517            && matches!(&kafka_config.subscription, TopicSubscription::Pattern(_))
3518        {
3519            return Err(ConnectorError::ConfigurationError(
3520                "Kafka topic patterns are unsupported with engine-owned assignment; declare the \
3521                 exact topic inventory so ownership and checkpoint cuts stay stable"
3522                    .into(),
3523            ));
3524        }
3525        if matches!(
3526            &kafka_config.startup_mode,
3527            StartupMode::SpecificOffsets(_) | StartupMode::Timestamp(_)
3528        ) {
3529            if matches!(&kafka_config.subscription, TopicSubscription::Pattern(_)) {
3530                return Err(ConnectorError::ConfigurationError(
3531                    "Kafka specific-offset/timestamp startup requires an explicit topic list"
3532                        .into(),
3533                ));
3534            }
3535            if self.vnode_assignment.is_some() {
3536                return Err(ConnectorError::ConfigurationError(
3537                    "Kafka specific-offset/timestamp startup is unsupported with vnode assignment"
3538                        .into(),
3539                ));
3540            }
3541        }
3542        if let StartupMode::SpecificOffsets(offsets) = &kafka_config.startup_mode {
3543            if let Some((&partition, &offset)) = offsets
3544                .iter()
3545                .find(|(partition, offset)| **partition < 0 || **offset < 0)
3546            {
3547                return Err(ConnectorError::ConfigurationError(format!(
3548                    "invalid Kafka specific position {partition}:{offset}: partition and offset must be non-negative"
3549                )));
3550            }
3551        }
3552        if delivery != DeliveryGuarantee::BestEffort
3553            && matches!(&kafka_config.startup_mode, StartupMode::GroupOffsets)
3554        {
3555            return Err(ConnectorError::ConfigurationError(
3556                "Kafka guaranteed delivery cannot use broker group offsets as its initial recovery \
3557                 authority; another group member can advance that cursor before LaminarDB seals \
3558                 it. Use earliest or explicit specific offsets"
3559                    .into(),
3560            ));
3561        }
3562        if delivery != DeliveryGuarantee::BestEffort
3563            && matches!(
3564                &kafka_config.startup_mode,
3565                StartupMode::Latest | StartupMode::Timestamp(_)
3566            )
3567        {
3568            return Err(ConnectorError::ConfigurationError(
3569                "Kafka guaranteed delivery requires a stable unrecorded-partition start; latest \
3570                 and timestamp-with-no-match can move forward across recovery. Use earliest or \
3571                 explicit specific offsets until checkpointed partition baselines are available"
3572                    .into(),
3573            ));
3574        }
3575
3576        // Guaranteed delivery never joins Kafka's rebalance protocol. Cluster mode assigns the
3577        // vnode subset; embedded/single-node mode manually assigns the full explicit inventory. A
3578        // topology change is picked up only by a fresh, checkpoint-positioned source instance.
3579
3580        let deterministic_unrecorded = is_resume || delivery != DeliveryGuarantee::BestEffort;
3581        let configured_source_name = config.get("laminar.source.name");
3582        if self.vnode_assignment.is_some() {
3583            if configured_source_name
3584                .is_some_and(|configured| configured != self.source_name.as_ref())
3585            {
3586                return Err(ConnectorError::ConfigurationError(format!(
3587                    "Kafka vnode assignment identity '{}' does not match canonical source name \
3588                     '{}'",
3589                    self.source_name,
3590                    configured_source_name.unwrap_or_default()
3591                )));
3592            }
3593        } else {
3594            self.source_name = Arc::from(configured_source_name.unwrap_or_default());
3595        }
3596        self.state = ConnectorState::Initializing;
3597        self.config = kafka_config.clone();
3598        self.delivery = delivery;
3599        self.offsets = installed_offsets;
3600        self.manual_topic_partitions.clear();
3601        self.manual_partition_baselines.clear();
3602        *lock_or_recover(&self.assignment_publication) =
3603            Arc::new(KafkaAssignmentPublication::default());
3604        self.rotation_partition_baseline_count
3605            .store(0, Ordering::Release);
3606        self.applied_rotation_baseline_version = None;
3607        self.reconciled_assignment_version
3608            .store(0, Ordering::Release);
3609        lock_or_recover(&self.offset_snapshot).clone_from(&self.offsets);
3610        self.deterministic_unrecorded_position
3611            .store(deterministic_unrecorded, Ordering::Release);
3612        if let Some(attempt) = resume_attempt {
3613            // Manual assignment has no rebalance callback, so explicitly arm
3614            // the first reader iteration to apply the installed exact cursor.
3615            self.assign_generation.fetch_add(1, Ordering::Release);
3616            info!(
3617                epoch = attempt.epoch,
3618                checkpoint_id = attempt.checkpoint_id,
3619                partition_count = self.offsets.partition_count(),
3620                "installed exact Kafka resume position before consumer activation"
3621            );
3622        }
3623
3624        // Re-select deserializer (factory defaults to JSON).
3625        if let Some(sr_client) = Self::build_sr_client(&kafka_config)? {
3626            let sr = Arc::new(sr_client);
3627            self.schema_registry = Some(Arc::clone(&sr));
3628            self.deserializer = if kafka_config.format == Format::Avro {
3629                Box::new(AvroDeserializer::with_schema_registry(sr))
3630            } else {
3631                select_deserializer(kafka_config.format)
3632            };
3633        } else if let Some(ref sr) = self.schema_registry {
3634            // Preserve SR client injected via with_schema_registry().
3635            self.deserializer = if kafka_config.format == Format::Avro {
3636                Box::new(AvroDeserializer::with_schema_registry(Arc::clone(sr)))
3637            } else {
3638                select_deserializer(kafka_config.format)
3639            };
3640        } else {
3641            self.deserializer = select_deserializer(kafka_config.format);
3642        }
3643
3644        // New deserializer has empty known_ids; reset evolution baseline to match.
3645        self.last_avro_schema = None;
3646
3647        // Override schema from SQL DDL if provided.
3648        if let Some(schema) = config.arrow_schema() {
3649            info!(
3650                fields = schema.fields().len(),
3651                "using SQL-defined schema for deserialization"
3652            );
3653            self.schema = schema;
3654        }
3655
3656        info!(
3657            brokers = %kafka_config.bootstrap_servers,
3658            subscription = ?kafka_config.subscription,
3659            group_id = %kafka_config.group_id,
3660            format = %kafka_config.format,
3661            schema_fields = self.schema.fields().len(),
3662            "starting Kafka source connector"
3663        );
3664
3665        Ok(KafkaStartPlan {
3666            config: kafka_config,
3667            delivery,
3668            is_resume,
3669            resume_inventory,
3670            resume_baselines,
3671        })
3672    }
3673}
3674impl KafkaSource {
3675    async fn assign_vnode_partitions(
3676        &mut self,
3677        consumer: &Arc<StreamConsumer<LaminarConsumerContext>>,
3678        config: &KafkaSourceConfig,
3679        delivery: DeliveryGuarantee,
3680        is_resume: bool,
3681        resume_baselines: &KafkaPartitionBaselines,
3682    ) -> Result<bool, ConnectorError> {
3683        // Engine-controlled partition assignment (cluster mode): map the
3684        // canonical source/topic/partition identity to a vnode and manually
3685        // `assign()` only those this node owns. Manual assign bypasses the broker callbacks,
3686        // so partitions are positioned here directly (checkpointed offset, else
3687        // the startup default). The reader loop re-binds on assignment rotation.
3688        //
3689        // Reset stale metadata so a re-`start()` that falls back to subscribe
3690        // doesn't leave `checkpoint()` filtering by a prior run's vnode ownership.
3691        self.vnode_partition_routes.clear();
3692        let vnode = self
3693            .vnode_assignment
3694            .as_ref()
3695            .map(|(r, s)| (Arc::clone(r), *s));
3696        let vnode_assigned = if let Some((registry, self_id)) = vnode {
3697            if let TopicSubscription::Topics(topics) = &config.subscription {
3698                let topic_meta = fetch_explicit_topic_metadata(
3699                    self.blocking_tasks.clone(),
3700                    Arc::clone(consumer),
3701                    topics.clone(),
3702                )
3703                .await?;
3704                let partition_routes = kafka_partition_routes(
3705                    self.source_name.as_ref(),
3706                    registry.vnode_count(),
3707                    &topic_meta,
3708                )?;
3709                let all_partitions: KafkaPartitionSet = topic_meta
3710                    .iter()
3711                    .flat_map(|(topic, count)| {
3712                        (0..*count).map(move |partition| (topic.to_string(), partition))
3713                    })
3714                    .collect();
3715                if let Some(unexpected) = self
3716                    .offsets
3717                    .to_topic_partition_list()
3718                    .elements()
3719                    .iter()
3720                    .find(|entry| {
3721                        !all_partitions.contains(&(entry.topic().to_string(), entry.partition()))
3722                    })
3723                {
3724                    return Err(ConnectorError::ConfigurationError(format!(
3725                        "Kafka resume checkpoint references partition '{}-{}' absent from the explicit topic inventory",
3726                        unexpected.topic(),
3727                        unexpected.partition()
3728                    )));
3729                }
3730                let requires_numeric_cut = delivery != DeliveryGuarantee::BestEffort;
3731                if requires_numeric_cut {
3732                    let low_watermarks = fetch_partition_low_watermarks(
3733                        self.blocking_tasks.clone(),
3734                        Arc::clone(consumer),
3735                        &all_partitions,
3736                    )
3737                    .await?;
3738                    let baselines = if is_resume {
3739                        validate_partition_baselines(resume_baselines, &all_partitions)?;
3740                        resume_baselines.clone()
3741                    } else {
3742                        low_watermarks.clone()
3743                    };
3744                    validate_positions_not_expired(
3745                        &self.offsets,
3746                        &baselines,
3747                        &low_watermarks,
3748                        &all_partitions,
3749                    )?;
3750                    self.manual_partition_baselines = baselines;
3751                }
3752                self.manual_topic_partitions = all_partitions;
3753                let default_offset = if self
3754                    .deterministic_unrecorded_position
3755                    .load(Ordering::Acquire)
3756                {
3757                    deterministic_initial_offset(&config.startup_mode, config.auto_offset_reset)
3758                        .ok_or_else(|| {
3759                            ConnectorError::ConfigurationError(
3760                                "Kafka startup mode has no deterministic vnode fallback".into(),
3761                            )
3762                        })?
3763                } else {
3764                    startup_default_offset(&config.startup_mode)
3765                };
3766                // start() already loaded committed offsets into self.offsets, so
3767                // there are no rotation handoff offsets at initial assignment.
3768                let no_resume = OffsetTracker::new();
3769                let no_resume_baselines = KafkaPartitionBaselines::new();
3770                // Pin the final ownership publication only across synchronous librdkafka calls.
3771                // Metadata and watermark I/O above must not delay assignment writers.
3772                let published = registry.read_assignment();
3773                let assignment_version = published.version();
3774                let boot_unassigned = kafka_bootstrap_is_unassigned(&published, self_id)?;
3775                let tpl = if boot_unassigned {
3776                    TopicPartitionList::new()
3777                } else {
3778                    build_vnode_assignment_tpl(
3779                        self.source_name.as_ref(),
3780                        published.owners(),
3781                        self_id,
3782                        &topic_meta,
3783                        VnodeResumeCursors {
3784                            local_offsets: &self.offsets,
3785                            handoff_offsets: &no_resume,
3786                            local_baselines: &self.manual_partition_baselines,
3787                            handoff_baselines: &no_resume_baselines,
3788                        },
3789                        default_offset,
3790                    )?
3791                };
3792                let owned_partitions = Arc::new(
3793                    kafka_partition_set(&tpl).map_err(ConnectorError::ConfigurationError)?,
3794                );
3795                // Incremental from empty so rebinds can stay incremental — librdkafka
3796                // rejects mixing a full assign() with incremental_assign/unassign.
3797                if tpl.count() > 0 {
3798                    consumer.incremental_assign(&tpl).map_err(|e| {
3799                        ConnectorError::ConnectionFailed(format!(
3800                            "vnode partition assign failed: {e}"
3801                        ))
3802                    })?;
3803                }
3804                validate_kafka_partition_results("initial incremental assign", &tpl)
3805                    .map_err(ConnectorError::ConnectionFailed)?;
3806                let active = consumer.assignment().map_err(|error| {
3807                    ConnectorError::ConnectionFailed(format!(
3808                        "failed to inspect initial vnode assignment: {error}"
3809                    ))
3810                })?;
3811                let active =
3812                    kafka_partition_set(&active).map_err(ConnectorError::ConnectionFailed)?;
3813                validate_kafka_assignment(&owned_partitions, &active)
3814                    .map_err(ConnectorError::ConnectionFailed)?;
3815                self.vnode_partition_routes = partition_routes;
3816                *lock_or_recover(&self.assignment_publication) =
3817                    Arc::new(KafkaAssignmentPublication::new(
3818                        assignment_version,
3819                        Arc::clone(&owned_partitions),
3820                        KafkaRotationBaselines::new(),
3821                    ));
3822                self.reconciled_assignment_version
3823                    .store(assignment_version, Ordering::Release);
3824                drop(published);
3825                if boot_unassigned {
3826                    info!(
3827                        "Kafka source started fenced with no partitions until durable vnode adoption"
3828                    );
3829                } else {
3830                    info!(
3831                        owned_partitions = owned_partitions.len(),
3832                        "Kafka source assigned vnode-owned partitions (engine-controlled)"
3833                    );
3834                }
3835                true
3836            } else {
3837                return Err(ConnectorError::ConfigurationError(
3838                    "Kafka vnode assignment requires an explicit topic inventory".into(),
3839                ));
3840            }
3841        } else {
3842            false
3843        };
3844
3845        Ok(vnode_assigned)
3846    }
3847}
3848impl KafkaSource {
3849    async fn assign_local_guaranteed_partitions(
3850        &mut self,
3851        consumer: &Arc<StreamConsumer<LaminarConsumerContext>>,
3852        config: &KafkaSourceConfig,
3853        delivery: DeliveryGuarantee,
3854        vnode_assigned: bool,
3855        is_resume: bool,
3856        resume_inventory: Option<&KafkaPartitionSet>,
3857        resume_baselines: &KafkaPartitionBaselines,
3858    ) -> Result<bool, ConnectorError> {
3859        let local_guaranteed_assignment = delivery != DeliveryGuarantee::BestEffort
3860            && !vnode_assigned
3861            && matches!(&config.startup_mode, StartupMode::Earliest);
3862        if local_guaranteed_assignment {
3863            let TopicSubscription::Topics(topics) = &config.subscription else {
3864                return Err(ConnectorError::ConfigurationError(
3865                    "Kafka guaranteed delivery requires an explicit topic inventory".into(),
3866                ));
3867            };
3868            let topic_meta = fetch_explicit_topic_metadata(
3869                self.blocking_tasks.clone(),
3870                Arc::clone(consumer),
3871                topics.clone(),
3872            )
3873            .await?;
3874            let assigned: Vec<(String, i32)> = topic_meta
3875                .iter()
3876                .flat_map(|(topic, count)| {
3877                    (0..*count).map(move |partition| (topic.to_string(), partition))
3878                })
3879                .collect();
3880            let assigned_set: KafkaPartitionSet = assigned.iter().cloned().collect();
3881            if let Some(unexpected) = self
3882                .offsets
3883                .to_topic_partition_list()
3884                .elements()
3885                .iter()
3886                .find(|entry| {
3887                    !assigned_set.contains(&(entry.topic().to_string(), entry.partition()))
3888                })
3889            {
3890                return Err(ConnectorError::ConfigurationError(format!(
3891                    "Kafka resume checkpoint references partition '{}-{}' absent from the explicit topic inventory",
3892                    unexpected.topic(),
3893                    unexpected.partition()
3894                )));
3895            }
3896            let low_watermarks = fetch_partition_low_watermarks(
3897                self.blocking_tasks.clone(),
3898                Arc::clone(consumer),
3899                &assigned_set,
3900            )
3901            .await?;
3902            let baselines = if is_resume {
3903                validate_resume_inventory(resume_inventory, &assigned_set)?;
3904                validate_partition_baselines(resume_baselines, &assigned_set)?;
3905                resume_baselines.clone()
3906            } else {
3907                low_watermarks.clone()
3908            };
3909            validate_positions_not_expired(
3910                &self.offsets,
3911                &baselines,
3912                &low_watermarks,
3913                &assigned_set,
3914            )?;
3915            let assignment =
3916                assignment_seek_tpl(&self.offsets, &assigned, Some(&baselines), None, true)?;
3917            consumer.assign(&assignment).map_err(|error| {
3918                ConnectorError::ConnectionFailed(format!(
3919                    "failed to install local guaranteed Kafka assignment: {error}"
3920                ))
3921            })?;
3922            self.manual_topic_partitions = assigned_set;
3923            self.manual_partition_baselines = baselines;
3924            info!(
3925                partition_count = assignment.count(),
3926                "Kafka source assigned full explicit inventory (local guaranteed delivery)"
3927            );
3928        }
3929
3930        Ok(local_guaranteed_assignment)
3931    }
3932}
3933impl KafkaSource {
3934    async fn prefetch_schema_registry(
3935        &mut self,
3936        config: &KafkaSourceConfig,
3937    ) -> Result<(), ConnectorError> {
3938        // Eagerly fetch the SR schema so the Arrow schema is available at
3939        // plan time (before the first poll_batch).
3940        if let Some(ref sr) = self.schema_registry {
3941            if let TopicSubscription::Topics(topics) = &config.subscription {
3942                if topics.len() > 1 {
3943                    warn!("multiple topics with schema registry — using first topic's schema");
3944                }
3945                if let Some(topic) = topics.first() {
3946                    let subject = resolve_value_subject(
3947                        config.schema_registry_subject_strategy,
3948                        config.schema_registry_record_name.as_deref(),
3949                        topic,
3950                    );
3951                    match tokio::time::timeout(
3952                        config.schema_registry_discovery_timeout,
3953                        sr.get_latest_schema(&subject),
3954                    )
3955                    .await
3956                    {
3957                        Ok(Ok(cached)) => {
3958                            if let Some(avro_deser) = self
3959                                .deserializer
3960                                .as_any_mut()
3961                                .and_then(|any| any.downcast_mut::<AvroDeserializer>())
3962                            {
3963                                if let Err(error) =
3964                                    avro_deser.register_schema(cached.id, &cached.schema_str)
3965                                {
3966                                    let error = ConnectorError::Serde(error);
3967                                    self.fail_startup();
3968                                    return Err(error);
3969                                }
3970                                // Keep the catalog schema pinned — planner
3971                                // plans are already built against it.
3972                                log_schema_drift(&self.schema, &cached.arrow_schema, &subject);
3973                                info!(%subject, schema_id = cached.id,
3974                                    "SR schema fetched at start()");
3975                                self.last_avro_schema = Some(cached.arrow_schema);
3976                            }
3977                        }
3978                        Ok(Err(e)) if e.is_transient() => {
3979                            warn!(%subject, error = %e, "SR unavailable at start(), will resolve lazily");
3980                        }
3981                        Ok(Err(e)) => {
3982                            self.fail_startup();
3983                            return Err(e);
3984                        }
3985                        Err(_elapsed) => {
3986                            warn!(%subject, "SR prefetch timed out at start(), will resolve lazily");
3987                        }
3988                    }
3989                }
3990            }
3991        }
3992
3993        Ok(())
3994    }
3995}
3996#[async_trait]
3997impl SourceConnector for KafkaSource {
3998    fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
3999        Some(self.task_tracker.clone())
4000    }
4001
4002    fn contract(&self, _config: &ConnectorConfig) -> Result<SourceContract, ConnectorError> {
4003        Ok(SourceContract::new(
4004            SourceConsistency::Replayable,
4005            SourceTopology::Splittable,
4006        ))
4007    }
4008
4009    fn set_vnode_assignment(
4010        &mut self,
4011        source_identity: &str,
4012        registry: Arc<laminar_core::state::VnodeRegistry>,
4013        self_id: laminar_core::state::NodeId,
4014    ) -> Result<(), ConnectorError> {
4015        if source_identity.is_empty() {
4016            return Err(ConnectorError::ConfigurationError(
4017                "Kafka vnode assignment requires a non-empty canonical source identity".into(),
4018            ));
4019        }
4020        if self_id.is_unassigned() {
4021            return Err(ConnectorError::ConfigurationError(
4022                "Kafka vnode assignment requires a nonzero node identity".into(),
4023            ));
4024        }
4025        info!(
4026            source = source_identity,
4027            self_id = self_id.0,
4028            vnode_count = registry.vnode_count(),
4029            "Kafka source: engine-controlled partition→vnode assignment enabled"
4030        );
4031        self.source_name = Arc::from(source_identity);
4032        self.vnode_assignment = Some((registry, self_id));
4033        Ok(())
4034    }
4035
4036    fn begin_drain(
4037        &mut self,
4038        request: &SourceDrainRequest,
4039        deadline: tokio::time::Instant,
4040    ) -> Result<(), ConnectorError> {
4041        self.check_reader_health("starting a global source drain")?;
4042        if let Some(active) = self.source_drain.as_ref() {
4043            if active.request != *request {
4044                return Err(ConnectorError::InvalidState {
4045                    expected: format!("active Kafka drain {:?}", active.request.round),
4046                    actual: format!("conflicting Kafka drain {:?}", request.round),
4047                });
4048            }
4049            // Retain the first preparation deadline. A caller retry carries a wait budget,
4050            // not authority to extend or shorten work already in progress.
4051            return Ok(());
4052        }
4053        if tokio::time::Instant::now() >= deadline {
4054            return Err(ConnectorError::Internal(
4055                "Kafka source drain began after its engine deadline".into(),
4056            ));
4057        }
4058        let Some((registry, _)) = self.vnode_assignment.as_ref() else {
4059            return Err(ConnectorError::InvalidState {
4060                expected: "Kafka cluster assignment installed before source drain".into(),
4061                actual: "embedded/single Kafka source".into(),
4062            });
4063        };
4064        if registry.assignment_version() != request.round.predecessor_version {
4065            return Err(ConnectorError::InvalidState {
4066                expected: format!(
4067                    "Kafka predecessor assignment {}",
4068                    request.round.predecessor_version
4069                ),
4070                actual: registry.assignment_version().to_string(),
4071            });
4072        }
4073        let reconciled = self.reconciled_assignment_version.load(Ordering::Acquire);
4074        if reconciled != request.round.predecessor_version {
4075            return Err(ConnectorError::InvalidState {
4076                expected: format!(
4077                    "reconciled Kafka predecessor assignment {}",
4078                    request.round.predecessor_version
4079                ),
4080                actual: reconciled.to_string(),
4081            });
4082        }
4083        self.ensure_reader_started();
4084        let tx = self.reader_drain_tx.as_ref().ok_or_else(|| {
4085            ConnectorError::Internal("Kafka cluster reader has no drain control channel".into())
4086        })?;
4087        tx.send(KafkaReaderDrainCommand::Begin {
4088            request: request.clone(),
4089            deadline,
4090        })
4091        .map_err(|_| ConnectorError::Internal("Kafka reader drain channel closed".into()))?;
4092        self.source_drain = Some(KafkaSourceDrain {
4093            request: request.clone(),
4094            prepare_deadline: deadline,
4095            boundary: None,
4096            cut: None,
4097            pending_resolution: None,
4098        });
4099        self.data_ready.notify_one();
4100        Ok(())
4101    }
4102
4103    fn poll_drain_ready(
4104        &mut self,
4105        round: laminar_core::checkpoint::AssignmentDrainId,
4106    ) -> Result<bool, ConnectorError> {
4107        self.check_reader_health("capturing a global source drain cut")?;
4108        let Some(active) = self.source_drain.as_ref() else {
4109            return Err(ConnectorError::InvalidState {
4110                expected: format!("active Kafka drain {round:?}"),
4111                actual: "no Kafka drain".into(),
4112            });
4113        };
4114        if active.request.round != round {
4115            return Err(ConnectorError::InvalidState {
4116                expected: format!("active Kafka drain {:?}", active.request.round),
4117                actual: format!("cut requested for {round:?}"),
4118            });
4119        }
4120        if active.cut.is_some() {
4121            return Ok(true);
4122        }
4123        if tokio::time::Instant::now() >= active.prepare_deadline {
4124            return Err(ConnectorError::Internal(
4125                "Kafka drain deadline expired before cursor capture".into(),
4126            ));
4127        }
4128        let Some(boundary) = active.boundary.clone() else {
4129            return Ok(false);
4130        };
4131        let cut = self.capture_drain_positions(&boundary.inputs, Some(active.prepare_deadline))?;
4132        let active = self.source_drain.as_mut().expect("checked above");
4133        active.cut = Some(cut);
4134        Ok(true)
4135    }
4136
4137    async fn finish_drain(
4138        &mut self,
4139        resolution: SourceDrainResolution,
4140        deadline: tokio::time::Instant,
4141    ) -> Result<(), ConnectorError> {
4142        self.check_reader_health("resolving a global source drain")?;
4143        let active = self
4144            .source_drain
4145            .as_ref()
4146            .ok_or_else(|| ConnectorError::InvalidState {
4147                expected: format!("active Kafka drain {:?}", resolution.round),
4148                actual: "no Kafka drain".into(),
4149            })?;
4150        if active.request.round != resolution.round {
4151            return Err(ConnectorError::InvalidState {
4152                expected: format!("active Kafka drain {:?}", active.request.round),
4153                actual: format!("resolution for {:?}", resolution.round),
4154            });
4155        }
4156        let cut =
4157            active
4158                .cut
4159                .as_ref()
4160                .map(Arc::clone)
4161                .ok_or_else(|| ConnectorError::InvalidState {
4162                    expected: "Kafka FIFO drain cut before resolution".into(),
4163                    actual: "drain boundary not consumed".into(),
4164                })?;
4165        if let Some(pending) = active.pending_resolution.as_ref() {
4166            if pending.resolution != resolution {
4167                return Err(ConnectorError::InvalidState {
4168                    expected: format!("pending Kafka resolution {:?}", pending.resolution),
4169                    actual: format!("conflicting Kafka resolution {resolution:?}"),
4170                });
4171            }
4172            // The first terminal command owns the resolution deadline. A retry waits on that
4173            // same command and cannot extend or shorten its provider execution window.
4174            if let Some(error) = pending.terminal_error.as_ref() {
4175                return Err(ConnectorError::Internal(error.to_string()));
4176            }
4177            if pending.execution.load(Ordering::Acquire) == KAFKA_DRAIN_EXECUTION_CANCELLED {
4178                return Err(ConnectorError::Internal(
4179                    "Kafka drain resolution was cancelled before execution".into(),
4180                ));
4181            }
4182        } else {
4183            if tokio::time::Instant::now() >= deadline {
4184                return Err(ConnectorError::Internal(
4185                    "Kafka drain engine deadline expired before resolution".into(),
4186                ));
4187            }
4188            let tx = self.reader_drain_tx.as_ref().ok_or_else(|| {
4189                ConnectorError::Internal("Kafka reader drain channel is absent".into())
4190            })?;
4191            let execution = Arc::new(AtomicU8::new(KAFKA_DRAIN_EXECUTION_PENDING));
4192            let (reply, result) = tokio::sync::oneshot::channel();
4193            tx.send(KafkaReaderDrainCommand::Resolve {
4194                resolution,
4195                cut,
4196                deadline,
4197                execution: Arc::clone(&execution),
4198                reply,
4199            })
4200            .map_err(|_| ConnectorError::Internal("Kafka reader drain channel closed".into()))?;
4201            self.source_drain
4202                .as_mut()
4203                .expect("active drain was validated above")
4204                .pending_resolution = Some(KafkaPendingDrainResolution {
4205                resolution,
4206                deadline,
4207                execution,
4208                reply: result,
4209                terminal_error: None,
4210            });
4211            self.data_ready.notify_one();
4212        }
4213        let pending = self
4214            .source_drain
4215            .as_mut()
4216            .and_then(|active| active.pending_resolution.as_mut())
4217            .expect("pending Kafka resolution was installed above");
4218        let execution = Arc::clone(&pending.execution);
4219        let mut wait_guard = KafkaDrainWaitGuard::new(Arc::clone(&execution));
4220        let reply = match tokio::time::timeout_at(pending.deadline, &mut pending.reply).await {
4221            Ok(reply) => reply,
4222            Err(_) => match execution.compare_exchange(
4223                KAFKA_DRAIN_EXECUTION_PENDING,
4224                KAFKA_DRAIN_EXECUTION_CANCELLED,
4225                Ordering::AcqRel,
4226                Ordering::Acquire,
4227            ) {
4228                Ok(_) | Err(KAFKA_DRAIN_EXECUTION_CANCELLED) => {
4229                    let error: Arc<str> = Arc::from(
4230                        "Kafka drain engine deadline expired before resolution completed",
4231                    );
4232                    pending.terminal_error = Some(Arc::clone(&error));
4233                    wait_guard.disarm();
4234                    return Err(ConnectorError::Internal(error.to_string()));
4235                }
4236                Err(KAFKA_DRAIN_EXECUTION_STARTED) => (&mut pending.reply).await,
4237                Err(state) => {
4238                    wait_guard.disarm();
4239                    return Err(ConnectorError::Internal(format!(
4240                        "Kafka drain resolution has invalid execution state {state}"
4241                    )));
4242                }
4243            },
4244        };
4245        wait_guard.disarm();
4246        match reply {
4247            Ok(Ok(())) => {
4248                self.source_drain = None;
4249                Ok(())
4250            }
4251            Ok(Err(error)) => {
4252                pending.terminal_error = Some(Arc::from(error.as_str()));
4253                Err(ConnectorError::Internal(error))
4254            }
4255            Err(_) => {
4256                let error: Arc<str> = Arc::from("Kafka reader dropped drain resolution");
4257                pending.terminal_error = Some(Arc::clone(&error));
4258                Err(ConnectorError::Internal(error.to_string()))
4259            }
4260        }
4261    }
4262
4263    async fn start(&mut self, request: SourceStart) -> Result<(), ConnectorError> {
4264        if self.state != ConnectorState::Created {
4265            return Err(ConnectorError::InvalidState {
4266                expected: ConnectorState::Created.to_string(),
4267                actual: self.state.to_string(),
4268            });
4269        }
4270        let KafkaStartPlan {
4271            config: kafka_config,
4272            delivery,
4273            is_resume,
4274            resume_inventory,
4275            resume_baselines,
4276        } = self.prepare_start(request)?;
4277        let mut rdkafka_config: ClientConfig = kafka_config.to_rdkafka_config();
4278        if delivery != DeliveryGuarantee::BestEffort
4279            || matches!(
4280                &kafka_config.startup_mode,
4281                StartupMode::SpecificOffsets(_) | StartupMode::Timestamp(_)
4282            )
4283        {
4284            // Once the engine owns the cursor, retention must surface as a fault. Allowing
4285            // librdkafka to auto-reset would silently cross the sealed checkpoint cut after the
4286            // preflight watermark validation (including a retention race while paused).
4287            rdkafka_config.set("auto.offset.reset", "error");
4288        }
4289        let context = LaminarConsumerContext::new(
4290            Arc::clone(&self.rebalance_state),
4291            Arc::clone(&self.rebalance_counter),
4292            Arc::clone(&self.revoke_generation),
4293            Arc::clone(&self.assign_generation),
4294            // IntCounter::clone is an Arc bump; these are shared with the
4295            // metrics struct and bumped from librdkafka's background thread
4296            // inside `commit_callback`.
4297            self.metrics.commits.clone(),
4298            self.metrics.commit_failures.clone(),
4299        );
4300        let consumer: StreamConsumer<LaminarConsumerContext> = rdkafka_config
4301            .create_with_context(context)
4302            .map_err(|error| consumer_creation_error(&error))?;
4303        // Install ownership before any fallible activation work. If metadata, assignment, or
4304        // subscription fails, the source task's cleanup path can move the final consumer drop to
4305        // the bounded blocking reaper instead of running librdkafka Drop on a Tokio worker.
4306        let consumer = Arc::new(consumer);
4307        self.consumer = Some(Arc::clone(&consumer));
4308
4309        let vnode_assigned = self
4310            .assign_vnode_partitions(
4311                &consumer,
4312                &kafka_config,
4313                delivery,
4314                is_resume,
4315                &resume_baselines,
4316            )
4317            .await?;
4318        let local_guaranteed_assignment = self
4319            .assign_local_guaranteed_partitions(
4320                &consumer,
4321                &kafka_config,
4322                delivery,
4323                vnode_assigned,
4324                is_resume,
4325                resume_inventory.as_ref(),
4326                &resume_baselines,
4327            )
4328            .await?;
4329        // Group modes subscribe only after the engine position has been staged.
4330        // Specific/timestamp modes use a fully positioned manual assignment, so
4331        // they never briefly join the group at an unrelated broker cursor.
4332        if !vnode_assigned && !local_guaranteed_assignment {
4333            match &kafka_config.startup_mode {
4334                StartupMode::GroupOffsets | StartupMode::Earliest | StartupMode::Latest => {
4335                    match &kafka_config.subscription {
4336                        TopicSubscription::Topics(topics) => {
4337                            let topic_refs: Vec<&str> = topics.iter().map(String::as_str).collect();
4338                            consumer.subscribe(&topic_refs).map_err(|e| {
4339                                ConnectorError::ConnectionFailed(format!(
4340                                    "failed to subscribe: {e}"
4341                                ))
4342                            })?;
4343                        }
4344                        TopicSubscription::Pattern(pattern) => {
4345                            let regex_pattern = if pattern.starts_with('^') {
4346                                pattern.clone()
4347                            } else {
4348                                format!("^{pattern}")
4349                            };
4350                            consumer.subscribe(&[&regex_pattern]).map_err(|e| {
4351                                ConnectorError::ConnectionFailed(format!(
4352                                    "failed to subscribe to pattern: {e}"
4353                                ))
4354                            })?;
4355                        }
4356                    }
4357                }
4358                StartupMode::SpecificOffsets(offsets) => {
4359                    let TopicSubscription::Topics(topics) = &kafka_config.subscription else {
4360                        return Err(ConnectorError::ConfigurationError(
4361                            "Kafka specific-offset startup requires an explicit topic inventory"
4362                                .into(),
4363                        ));
4364                    };
4365                    let mut assigned: Vec<(String, i32)> = topics
4366                        .iter()
4367                        .flat_map(|topic| {
4368                            offsets
4369                                .keys()
4370                                .copied()
4371                                .map(move |partition| (topic.clone(), partition))
4372                        })
4373                        .collect();
4374                    assigned.sort_unstable();
4375                    if assigned.is_empty() {
4376                        return Err(ConnectorError::ConfigurationError(
4377                            "Kafka specific-offset startup resolved no partitions".into(),
4378                        ));
4379                    }
4380                    let assigned_set: KafkaPartitionSet = assigned.iter().cloned().collect();
4381                    let configured_baselines: KafkaPartitionBaselines = topics
4382                        .iter()
4383                        .flat_map(|topic| {
4384                            offsets
4385                                .iter()
4386                                .map(move |(&partition, &next)| ((topic.clone(), partition), next))
4387                        })
4388                        .collect();
4389                    let baselines = if is_resume {
4390                        validate_resume_inventory(resume_inventory.as_ref(), &assigned_set)?;
4391                        validate_partition_baselines(&resume_baselines, &assigned_set)?;
4392                        if resume_baselines != configured_baselines {
4393                            return Err(ConnectorError::ConfigurationError(
4394                                "Kafka specific-offset configuration changed across recovery"
4395                                    .into(),
4396                            ));
4397                        }
4398                        resume_baselines.clone()
4399                    } else {
4400                        configured_baselines
4401                    };
4402                    let low_watermarks = fetch_partition_low_watermarks(
4403                        self.blocking_tasks.clone(),
4404                        Arc::clone(&consumer),
4405                        &assigned_set,
4406                    )
4407                    .await?;
4408                    validate_positions_not_expired(
4409                        &self.offsets,
4410                        &baselines,
4411                        &low_watermarks,
4412                        &assigned_set,
4413                    )?;
4414                    let assignment = assignment_seek_tpl(
4415                        &self.offsets,
4416                        &assigned,
4417                        Some(&baselines),
4418                        None,
4419                        true,
4420                    )?;
4421                    consumer.assign(&assignment).map_err(|e| {
4422                        ConnectorError::ConnectionFailed(format!(
4423                            "failed to assign specific offsets: {e}"
4424                        ))
4425                    })?;
4426                    self.manual_topic_partitions = assigned_set;
4427                    self.manual_partition_baselines = baselines;
4428                    info!(
4429                        partition_count = assignment.count(),
4430                        "assigned consumer to exact checkpoint/specific offsets"
4431                    );
4432                }
4433                StartupMode::Timestamp(ts_ms) => {
4434                    let TopicSubscription::Topics(topics) = &kafka_config.subscription else {
4435                        return Err(ConnectorError::ConfigurationError(
4436                            "Kafka timestamp startup requires an explicit topic inventory".into(),
4437                        ));
4438                    };
4439                    let topic_meta = fetch_explicit_topic_metadata(
4440                        self.blocking_tasks.clone(),
4441                        Arc::clone(&consumer),
4442                        topics.clone(),
4443                    )
4444                    .await?;
4445                    let mut tpl = rdkafka::TopicPartitionList::new();
4446                    for (topic, partition_count) in &topic_meta {
4447                        for partition in 0..*partition_count {
4448                            tpl.add_partition_offset(
4449                                topic,
4450                                partition,
4451                                rdkafka::Offset::Offset(*ts_ms),
4452                            )
4453                            .map_err(|error| {
4454                                ConnectorError::Internal(format!(
4455                                    "failed to build timestamp lookup for '{topic}-{partition}': {error}"
4456                                ))
4457                            })?;
4458                        }
4459                    }
4460                    if tpl.count() == 0 {
4461                        return Err(ConnectorError::ConfigurationError(
4462                            "Kafka timestamp startup discovered no partitions".into(),
4463                        ));
4464                    }
4465                    let resolved = resolve_timestamp_offsets(
4466                        self.blocking_tasks.clone(),
4467                        Arc::clone(&consumer),
4468                        tpl,
4469                    )
4470                    .await?;
4471                    let mut positioned = TopicPartitionList::new();
4472                    let mut restored = 0usize;
4473                    for elem in resolved.elements() {
4474                        if let Err(e) = elem.error() {
4475                            return Err(ConnectorError::ConnectionFailed(format!(
4476                                "timestamp lookup failed for '{}-{}': {e}",
4477                                elem.topic(),
4478                                elem.partition()
4479                            )));
4480                        }
4481                        let offset = if let Some(checkpointed) =
4482                            self.offsets.get(elem.topic(), elem.partition())
4483                        {
4484                            restored += 1;
4485                            rdkafka::Offset::Offset(checkpointed.checked_add(1).ok_or_else(
4486                                || {
4487                                    ConnectorError::ConfigurationError(format!(
4488                                        "Kafka checkpoint offset overflow for '{}-{}'",
4489                                        elem.topic(),
4490                                        elem.partition()
4491                                    ))
4492                                },
4493                            )?)
4494                        } else if elem.offset() == rdkafka::Offset::Invalid {
4495                            // No record at/after the timestamp is a deterministic
4496                            // start at the current end, not a broker group cursor.
4497                            rdkafka::Offset::End
4498                        } else {
4499                            elem.offset()
4500                        };
4501                        positioned
4502                            .add_partition_offset(elem.topic(), elem.partition(), offset)
4503                            .map_err(|e| {
4504                                ConnectorError::Internal(format!(
4505                                    "failed to build timestamp assignment for '{}-{}': {e}",
4506                                    elem.topic(),
4507                                    elem.partition()
4508                                ))
4509                            })?;
4510                    }
4511                    if restored != self.offsets.partition_count() {
4512                        return Err(ConnectorError::ConfigurationError(
4513                            "Kafka resume checkpoint references a partition absent from timestamp metadata"
4514                                .into(),
4515                        ));
4516                    }
4517                    consumer.assign(&positioned).map_err(|e| {
4518                        ConnectorError::ConnectionFailed(format!(
4519                            "failed to assign timestamp/checkpoint offsets: {e}"
4520                        ))
4521                    })?;
4522                    self.manual_topic_partitions = positioned
4523                        .elements()
4524                        .iter()
4525                        .map(|entry| (entry.topic().to_string(), entry.partition()))
4526                        .collect();
4527                    info!(
4528                        timestamp_ms = ts_ms,
4529                        partition_count = positioned.count(),
4530                        restored_partitions = restored,
4531                        "assigned consumer to exact checkpoint/timestamp offsets"
4532                    );
4533                }
4534            }
4535        } // end `if !vnode_assigned && !local_guaranteed_assignment`
4536
4537        // Reader startup stays deferred until the first poll. Group
4538        // assignments are paused by the callback and explicitly seeked from
4539        // the position installed above before any record can enter the channel.
4540
4541        self.prefetch_schema_registry(&kafka_config).await?;
4542
4543        self.state = ConnectorState::Running;
4544        info!("Kafka source connector started successfully");
4545        Ok(())
4546    }
4547
4548    async fn discover_schema(
4549        &mut self,
4550        properties: &std::collections::HashMap<String, String>,
4551    ) -> Result<(), ConnectorError> {
4552        let cfg = crate::config::ConnectorConfig::with_properties("kafka", properties.clone());
4553        let kafka_config = KafkaSourceConfig::from_config(&cfg)?;
4554        if kafka_config.format != Format::Avro {
4555            return Ok(());
4556        }
4557
4558        let topic = match &kafka_config.subscription {
4559            TopicSubscription::Topics(topics) => match topics.first() {
4560                Some(t) => {
4561                    if topics.len() > 1 {
4562                        warn!(topics = ?topics, chosen = %t,
4563                            "multi-topic source: using first topic's SR schema");
4564                    }
4565                    t.clone()
4566                }
4567                None => return Ok(()),
4568            },
4569            TopicSubscription::Pattern(pattern) => {
4570                return Err(ConnectorError::ConfigurationError(format!(
4571                    "topic.pattern '{pattern}' cannot auto-discover a schema; \
4572                     declare columns explicitly"
4573                )));
4574            }
4575        };
4576
4577        let Some(sr_client) = Self::build_sr_client(&kafka_config)? else {
4578            return Ok(());
4579        };
4580
4581        let subject = resolve_value_subject(
4582            kafka_config.schema_registry_subject_strategy,
4583            kafka_config.schema_registry_record_name.as_deref(),
4584            &topic,
4585        );
4586        let timeout = kafka_config.schema_registry_discovery_timeout;
4587
4588        match tokio::time::timeout(timeout, sr_client.get_latest_schema(&subject)).await {
4589            Ok(Ok(cached)) => {
4590                self.metrics.record_sr_discovery_success();
4591                info!(%subject, schema_id = cached.id,
4592                    fields = cached.arrow_schema.fields().len(),
4593                    "discovered Avro schema from Schema Registry");
4594                self.schema = cached.arrow_schema;
4595                Ok(())
4596            }
4597            Ok(Err(e)) => {
4598                self.metrics.record_sr_discovery_failure();
4599                Err(e)
4600            }
4601            Err(_) => {
4602                self.metrics.record_sr_discovery_timeout();
4603                Err(ConnectorError::Timeout(
4604                    u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX),
4605                ))
4606            }
4607        }
4608    }
4609
4610    async fn poll_batch(
4611        &mut self,
4612        max_records: usize,
4613    ) -> Result<Option<SourceBatch>, ConnectorError> {
4614        if self.state != ConnectorState::Running {
4615            return Err(ConnectorError::InvalidState {
4616                expected: "Running".into(),
4617                actual: self.state.to_string(),
4618            });
4619        }
4620
4621        // Lazily spawn the background reader task on first poll.
4622        self.ensure_reader_started();
4623        self.check_reader_health("polling source data")?;
4624
4625        let limit = max_records.min(self.config.max_poll_records);
4626
4627        // Reuse struct-level buffers — clear without freeing capacity. Clearing staged offsets
4628        // here also discards any left by a prior poll whose output failed to finalize.
4629        self.poll_payloads.clear();
4630        self.poll_payload_buf.clear();
4631        self.poll_payload_offsets.clear();
4632        self.poll_staged_offsets.clear();
4633        self.poll_meta_partitions.clear();
4634        self.poll_meta_offsets.clear();
4635        self.poll_meta_timestamps.clear();
4636        self.poll_meta_headers.clear();
4637
4638        let mut total_bytes: u64 = 0;
4639        let include_metadata = self.config.include_metadata;
4640        let include_headers = self.config.include_headers;
4641
4642        // Pin one ownership publication only while draining the non-awaiting
4643        // payload queue. An assignment writer waits for this short read-side
4644        // critical section, so one cut cannot mix ownership publications.
4645        let (drained_assignment_version, retires_rotation_baseline) = {
4646            let vnode_registry = self
4647                .vnode_assignment
4648                .as_ref()
4649                .map(|(registry, self_id)| (Arc::clone(registry), *self_id));
4650            let vnode_publication = vnode_registry
4651                .as_ref()
4652                .map(|(registry, self_id)| (registry.read_assignment(), *self_id));
4653            if let Some((published, _)) = vnode_publication.as_ref() {
4654                if self.reconciled_assignment_version.load(Ordering::Acquire) != published.version()
4655                {
4656                    return Ok(None);
4657                }
4658            }
4659
4660            // A vnode acquired after rehydration may also have a stale local cursor from an earlier
4661            // ownership stint. Keep the durable handoff baseline authoritative until this instance
4662            // has actually accepted a record from the acquired assignment.
4663            let rotation_baselines = if self
4664                .rotation_partition_baseline_count
4665                .load(Ordering::Acquire)
4666                == 0
4667            {
4668                None
4669            } else {
4670                Some(Arc::clone(&lock_or_recover(&self.assignment_publication)))
4671            };
4672            if let Some(publication) = rotation_baselines.as_deref() {
4673                if self.applied_rotation_baseline_version != Some(publication.assignment_version) {
4674                    let mut snapshot = lock_or_recover(&self.offset_snapshot);
4675                    for (topic, partitions) in &publication.baselines {
4676                        for partition in partitions.keys() {
4677                            self.offsets.remove(topic, *partition);
4678                            snapshot.remove(topic, *partition);
4679                        }
4680                    }
4681                    self.applied_rotation_baseline_version = Some(publication.assignment_version);
4682                }
4683            }
4684            let vnode_ownership = vnode_publication
4685                .as_ref()
4686                .map(|(published, self_id)| (published.owners(), *self_id));
4687
4688            let rx = self
4689                .msg_rx
4690                .as_mut()
4691                .ok_or_else(|| ConnectorError::InvalidState {
4692                    expected: "reader initialized".into(),
4693                    actual: "reader is None".into(),
4694                })?;
4695
4696            while self.poll_payloads.len() < limit {
4697                match rx.try_recv() {
4698                    Ok(item) => {
4699                        self.channel_len.fetch_sub(1, Ordering::Release);
4700                        let kp = match item {
4701                            KafkaReaderItem::Payload(payload) => payload,
4702                            KafkaReaderItem::DrainBoundary(boundary) => {
4703                                let Some(active) = self.source_drain.as_mut() else {
4704                                    self.state = ConnectorState::Failed;
4705                                    return Err(ConnectorError::Internal(
4706                                        "Kafka reader emitted a drain boundary without an active round"
4707                                            .into(),
4708                                    ));
4709                                };
4710                                if boundary.round != active.request.round
4711                                    || active.boundary.is_some()
4712                                {
4713                                    self.state = ConnectorState::Failed;
4714                                    return Err(ConnectorError::Internal(
4715                                        "Kafka reader emitted a stale or duplicate drain boundary"
4716                                            .into(),
4717                                    ));
4718                                }
4719                                active.boundary = Some(boundary);
4720                                // Never consume a post-boundary payload in this poll. If payloads
4721                                // were collected first, they are returned and sent before the
4722                                // source task is allowed to publish Ready.
4723                                break;
4724                            }
4725                        };
4726                        let payload_is_current = vnode_payload_is_current(
4727                            vnode_ownership
4728                                .as_ref()
4729                                .map(|(assignment, self_id)| (*assignment, *self_id)),
4730                            kp.partition_vnode,
4731                            rotation_baselines.as_deref().and_then(|publication| {
4732                                rotation_partition_baseline(
4733                                    &publication.baselines,
4734                                    kp.topic.as_ref(),
4735                                    kp.partition,
4736                                )
4737                            }),
4738                            kp.offset,
4739                        )
4740                        .map_err(|error| {
4741                            terminalize_guaranteed_poll_error(
4742                                self.delivery,
4743                                &mut self.state,
4744                                &self.metrics,
4745                                self.reader_shutdown.as_ref(),
4746                                error,
4747                            )
4748                        })?;
4749                        if !payload_is_current {
4750                            debug!(
4751                                topic = kp.topic.as_ref(),
4752                                partition = kp.partition,
4753                                offset = kp.offset,
4754                                "discarded Kafka payload outside the current vnode handoff cut"
4755                            );
4756                            continue;
4757                        }
4758                        self.poll_payloads.push(kp);
4759                    }
4760                    Err(crossfire::TryRecvError::Empty) => break,
4761                    Err(crossfire::TryRecvError::Disconnected) => {
4762                        self.state = ConnectorState::Failed;
4763                        return Err(ConnectorError::Internal(
4764                            "Kafka reader task exited unexpectedly".into(),
4765                        ));
4766                    }
4767                }
4768            }
4769            let assignment_version = vnode_publication
4770                .as_ref()
4771                .map(|(published, _)| published.version());
4772            let retires_baseline = rotation_baselines.as_deref().is_some_and(|publication| {
4773                self.poll_payloads.iter().any(|payload| {
4774                    rotation_partition_baseline(
4775                        &publication.baselines,
4776                        payload.topic.as_ref(),
4777                        payload.partition,
4778                    )
4779                    .is_some_and(|next| payload.offset >= next)
4780                })
4781            });
4782            (assignment_version, retires_baseline)
4783        };
4784        // Schema Registry resolution and decode can await or consume substantial CPU. They do not
4785        // inspect ownership, so release the publication before either operation; otherwise a slow
4786        // registry request would stall assignment activation for the full network timeout.
4787        for kp in self.poll_payloads.drain(..) {
4788            total_bytes += kp.data.len() as u64;
4789            let start = self.poll_payload_buf.len();
4790            self.poll_payload_buf.extend_from_slice(&kp.data);
4791            self.poll_payload_offsets.push((start, kp.data.len()));
4792
4793            // Stage the offset; it is folded into `self.offsets` only after the complete output
4794            // finalizes, so decode or metadata construction failure cannot advance past it.
4795            self.poll_staged_offsets
4796                .push((Arc::clone(&kp.topic), kp.partition, kp.offset));
4797
4798            if include_metadata {
4799                self.poll_meta_partitions.push(kp.partition);
4800                self.poll_meta_offsets.push(kp.offset);
4801                self.poll_meta_timestamps.push(kp.timestamp_ms);
4802            }
4803            if include_headers {
4804                self.poll_meta_headers.push(kp.headers_json);
4805            }
4806        }
4807
4808        // Sync rebalance counter → metrics (bridge from rdkafka background thread).
4809        let rebalance_events = self.rebalance_counter.swap(0, Ordering::Relaxed);
4810        for _ in 0..rebalance_events {
4811            self.metrics.record_rebalance();
4812        }
4813
4814        // Lock-free revoke detection: check if a rebalance revoke happened
4815        // since the last poll cycle. If so, purge offsets for revoked partitions.
4816        let current_revoke_gen = self.revoke_generation.load(Ordering::Acquire);
4817        let had_revoke = current_revoke_gen != self.last_seen_revoke_gen;
4818        if had_revoke {
4819            self.last_seen_revoke_gen = current_revoke_gen;
4820            let assigned = lock_or_recover(&self.rebalance_state).assignment_snapshot();
4821            let before = self.offsets.partition_count();
4822            self.offsets.retain_assigned(&assigned);
4823            lock_or_recover(&self.offset_snapshot).retain_assigned(&assigned);
4824            let after = self.offsets.partition_count();
4825            if before != after {
4826                debug!(
4827                    before,
4828                    after, "purged revoked partition offsets after rebalance"
4829                );
4830            }
4831        }
4832
4833        if self.poll_payload_offsets.is_empty() {
4834            return Ok(None);
4835        }
4836
4837        let (batch, good_indices) = self.decode_polled_payloads().await?;
4838
4839        let batch = self.append_metadata_columns(
4840            batch,
4841            good_indices.as_deref(),
4842            include_metadata,
4843            include_headers,
4844        )?;
4845        // Construct the complete output before publishing its cursor. In particular,
4846        // metadata/header column validation above is fallible and must not retire a rotation
4847        // baseline or advance the recovery position for a batch that cannot be returned.
4848        let output = SourceBatch::new(batch);
4849        let num_rows = output.num_rows();
4850
4851        if !self.poll_staged_offsets.is_empty() {
4852            {
4853                let mut snapshot = lock_or_recover(&self.offset_snapshot);
4854                for (topic, partition, offset) in &self.poll_staged_offsets {
4855                    self.offsets.update_arc(topic, *partition, *offset);
4856                    snapshot.update_arc(topic, *partition, *offset);
4857                }
4858            }
4859            if retires_rotation_baseline {
4860                if let Some(version) = drained_assignment_version {
4861                    let mut published = lock_or_recover(&self.assignment_publication);
4862                    if published.assignment_version == version {
4863                        let publication = Arc::make_mut(&mut published);
4864                        retire_accepted_rotation_baselines(
4865                            &mut publication.baselines,
4866                            &self.poll_staged_offsets,
4867                        );
4868                        let count = rotation_baselines_len(&publication.baselines);
4869                        self.rotation_partition_baseline_count
4870                            .store(count, Ordering::Release);
4871                    }
4872                }
4873            }
4874            self.poll_staged_offsets.clear();
4875        }
4876
4877        self.metrics.record_poll(num_rows as u64, total_bytes);
4878
4879        debug!(
4880            records = num_rows,
4881            bytes = total_bytes,
4882            "polled batch from Kafka"
4883        );
4884
4885        Ok(Some(output))
4886    }
4887
4888    fn schema(&self) -> SchemaRef {
4889        self.schema.clone()
4890    }
4891
4892    fn checkpoint_ready(&self) -> Result<bool, ConnectorError> {
4893        self.check_reader_health("reconciling source ownership")?;
4894        Ok(self.vnode_assignment.as_ref().is_none_or(|(registry, _)| {
4895            let version = registry.assignment_version();
4896            version != 0 && self.reconciled_assignment_version.load(Ordering::Acquire) == version
4897        }))
4898    }
4899
4900    fn drive_control_plane(&mut self) {
4901        self.ensure_reader_started();
4902    }
4903
4904    fn checkpoint(&self) -> SourceCheckpoint {
4905        self.try_capture_checkpoint()
4906            .ok()
4907            .flatten()
4908            .unwrap_or_default()
4909    }
4910
4911    fn try_checkpoint(&self) -> Result<Option<SourceCheckpoint>, ConnectorError> {
4912        self.validate_active_drain_cursor()?;
4913        self.try_capture_checkpoint()
4914    }
4915
4916    fn data_ready_notify(&self) -> Option<Arc<Notify>> {
4917        Some(Arc::clone(&self.data_ready))
4918    }
4919
4920    async fn notify_epoch_committed(
4921        &mut self,
4922        epoch: u64,
4923        checkpoint: &SourceCheckpoint,
4924    ) -> Result<(), ConnectorError> {
4925        if !self.config.broker_commit_on_checkpoint || checkpoint.is_empty() {
4926            return Ok(());
4927        }
4928        let tpl = OffsetTracker::try_from_checkpoint(checkpoint)?.to_topic_partition_list();
4929        if tpl.count() == 0 {
4930            return Ok(());
4931        }
4932        let Some(consumer) = self.consumer.as_ref() else {
4933            // Engine recovery never uses broker-stored offsets. A missing consumer cannot
4934            // invalidate the already-durable checkpoint, so report the observability failure
4935            // without turning a committed epoch into a pipeline restart loop.
4936            self.metrics.commit_failures.inc();
4937            warn!(
4938                epoch,
4939                "Kafka progress commit skipped because the consumer is absent"
4940            );
4941            return Ok(());
4942        };
4943
4944        // The engine checkpoint is the recovery authority; Kafka's group offset is an
4945        // observability cursor only. Enqueue it without blocking the checkpoint/recovery path.
4946        // The consumer context records the eventual broker acknowledgement or rejection.
4947        if let Err(error) = consumer.commit(&tpl, CommitMode::Async) {
4948            self.metrics.commit_failures.inc();
4949            warn!(epoch, %error, "Kafka progress commit was not accepted for enqueue");
4950        }
4951        Ok(())
4952    }
4953
4954    async fn close(&mut self) -> Result<(), ConnectorError> {
4955        info!("closing Kafka source connector");
4956
4957        // Stop intake and give the reader plus final consumer reaper one shared cleanup budget
4958        // below the coordinator's outer source-shutdown deadline.
4959        if let Some(tx) = self.reader_shutdown.take() {
4960            let _ = tx.send(true);
4961        }
4962        // Wake assignment and poll work before joining. Any advisory async commit cleanup remains
4963        // librdkafka-owned and is not allowed to extend the engine's source-shutdown deadline.
4964        if let Some(ref consumer) = self.consumer {
4965            consumer.unsubscribe();
4966        }
4967        let deadline = tokio::time::Instant::now() + KAFKA_BACKGROUND_CLOSE_BUDGET;
4968        join_background_task(&mut self.reader_handle, deadline, "reader").await;
4969        self.msg_rx = None;
4970        self.reader_drain_tx = None;
4971        self.source_drain = None;
4972        self.channel_len.store(0, Ordering::Release);
4973        if let Some(consumer) = self.consumer.take() {
4974            reap_last_arc_off_runtime(&self.blocking_tasks, consumer, deadline, "consumer").await;
4975        }
4976        if !self.blocking_tasks.join_until(deadline).await {
4977            self.blocking_tasks.ensure_reaper();
4978        }
4979        self.state = ConnectorState::Closed;
4980        info!("Kafka source connector closed");
4981        Ok(())
4982    }
4983}
4984
4985impl Drop for KafkaSource {
4986    fn drop(&mut self) {
4987        if let Some(shutdown) = self.reader_shutdown.take() {
4988            let _ = shutdown.send(true);
4989        }
4990        if let Some(consumer) = self.consumer.as_ref() {
4991            consumer.unsubscribe();
4992        }
4993        self.blocking_tasks.retire();
4994        if let Some(handle) = self.reader_handle.take() {
4995            ensure_background_task_reaper(handle, &self.task_owner, "reader");
4996        }
4997        self.blocking_tasks.ensure_reaper();
4998        if let Some(consumer) = self.consumer.take() {
4999            self.blocking_tasks.spawn_final_drop(consumer, "consumer");
5000        }
5001    }
5002}
5003
5004impl std::fmt::Debug for KafkaSource {
5005    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5006        f.debug_struct("KafkaSource")
5007            .field("state", &self.state)
5008            .field("subscription", &self.config.subscription)
5009            .field("group_id", &self.config.group_id)
5010            .field("format", &self.config.format)
5011            .field("delivery", &self.delivery)
5012            .field("partitions", &self.offsets.partition_count())
5013            .finish_non_exhaustive()
5014    }
5015}
5016
5017/// Warn if the CREATE-SOURCE catalog schema has drifted from the live
5018/// Schema Registry schema. Empty `declared` means nothing was declared.
5019fn log_schema_drift(declared: &arrow_schema::Schema, live: &arrow_schema::Schema, subject: &str) {
5020    if declared.fields().is_empty() || declared.fields() == live.fields() {
5021        return;
5022    }
5023    let decl: BTreeSet<&str> = declared
5024        .fields()
5025        .iter()
5026        .map(|f| f.name().as_str())
5027        .collect();
5028    let lv: BTreeSet<&str> = live.fields().iter().map(|f| f.name().as_str()).collect();
5029    warn!(
5030        %subject,
5031        missing_in_sr = ?decl.difference(&lv).collect::<Vec<_>>(),
5032        added_in_sr = ?lv.difference(&decl).collect::<Vec<_>>(),
5033        "schema drift: re-apply CREATE SOURCE DDL to pick up the current SR schema"
5034    );
5035}
5036
5037fn select_deserializer(format: Format) -> Box<dyn RecordDeserializer> {
5038    match format {
5039        Format::Avro => Box::new(AvroDeserializer::new()),
5040        other => serde::create_deserializer(other).unwrap_or_else(|_| {
5041            warn!(format = %other, "unsupported format, falling back to JSON");
5042            Box::new(serde::json::JsonDeserializer::new())
5043        }),
5044    }
5045}
5046
5047#[cfg(test)]
5048mod tests;