Skip to main content

faucet_source_kafka/
stream.rs

1//! `KafkaSource` — the Kafka consumer implementation.
2
3use crate::config::KafkaSourceConfig;
4use crate::context::BookmarkContext;
5use crate::decode;
6use crate::shard::{MemberShard, commit_list, parse_member_shard, plan_member_shards};
7use crate::state::{Bookmark, PartitionOffset, state_key};
8use async_trait::async_trait;
9use base64::Engine;
10use faucet_common_kafka::OnDecodeError;
11use faucet_core::shard::ShardSpec;
12use faucet_core::{FaucetError, Source, Stream, StreamPage};
13use rdkafka::config::RDKafkaLogLevel;
14use rdkafka::consumer::{CommitMode, Consumer, StreamConsumer};
15use rdkafka::message::Headers;
16use rdkafka::{ClientConfig, Message, Offset, TopicPartitionList};
17use serde_json::{Map, Value, json};
18use std::collections::{HashMap, HashSet};
19use std::pin::Pin;
20use std::sync::Arc;
21use std::sync::atomic::Ordering;
22use std::time::{Duration, Instant};
23
24#[cfg(feature = "schema-registry")]
25use faucet_common_kafka::KafkaValueFormat;
26#[cfg(feature = "schema-registry")]
27use faucet_common_kafka::schema_registry::client::SchemaRegistryClient;
28
29pub struct KafkaSource {
30    config: KafkaSourceConfig,
31    consumer: Arc<StreamConsumer<BookmarkContext>>,
32    context: BookmarkContext,
33    state_key_value: String,
34    /// Cache of watermark-derived floor offsets for assigned partitions that
35    /// have produced no message (so `position()` reports no concrete offset).
36    /// Resolved once per partition and reused so the streaming bookmark build
37    /// doesn't issue a broker `fetch_watermarks` every page (#146 H9).
38    assigned_floor: std::sync::Mutex<HashMap<(String, i32), i64>>,
39    /// The group-member shard applied by a cluster coordinator (Mode B, #261),
40    /// or `None` for a plain single-consumer run. `Mutex` so
41    /// `apply_shard(&self, …)` can record it before streaming.
42    member_shard: std::sync::Mutex<Option<MemberShard>>,
43    #[cfg(feature = "schema-registry")]
44    sr_client: Option<SchemaRegistryClient>,
45}
46
47impl KafkaSource {
48    pub async fn new(config: KafkaSourceConfig) -> Result<Self, FaucetError> {
49        config.validate()?;
50
51        let mut client_config = ClientConfig::new();
52        client_config.set("bootstrap.servers", &config.brokers);
53        client_config.set("group.id", &config.group_id);
54        client_config.set("enable.auto.commit", "false");
55        client_config.set("auto.offset.reset", config.auto_offset_reset.as_str());
56        client_config.set(
57            "session.timeout.ms",
58            config.session_timeout.as_millis().to_string(),
59        );
60        client_config.set_log_level(RDKafkaLogLevel::Warning);
61
62        config.auth.apply(&mut client_config)?;
63
64        for (k, v) in &config.extra_client_config {
65            client_config.set(k, v);
66        }
67
68        let context = BookmarkContext::new();
69        let consumer: StreamConsumer<BookmarkContext> = client_config
70            .create_with_context(context.clone())
71            .map_err(|e| FaucetError::Source(format!("kafka consumer init: {e}")))?;
72
73        let topic_refs: Vec<&str> = config.topics.iter().map(String::as_str).collect();
74        consumer
75            .subscribe(&topic_refs)
76            .map_err(|e| FaucetError::Source(format!("kafka subscribe: {e}")))?;
77
78        let state_key_value = state_key(&config.group_id, &config.topics);
79
80        #[cfg(feature = "schema-registry")]
81        let sr_client = build_sr_client(&config.value_format, config.key_format.as_ref())?;
82
83        Ok(Self {
84            config,
85            consumer: Arc::new(consumer),
86            context,
87            state_key_value,
88            assigned_floor: std::sync::Mutex::new(HashMap::new()),
89            member_shard: std::sync::Mutex::new(None),
90            #[cfg(feature = "schema-registry")]
91            sr_client,
92        })
93    }
94
95    /// Drain the callback-error slot. Called once per poll iteration so
96    /// rebalance-callback failures surface before any further messages are
97    /// processed (matches the batch-mode invariant).
98    fn check_callback_error(&self) -> Result<(), FaucetError> {
99        let mut guard =
100            self.context.callback_error.lock().map_err(|e| {
101                FaucetError::State(format!("kafka callback_error mutex poisoned: {e}"))
102            })?;
103        if let Some(e) = guard.take() {
104            return Err(e);
105        }
106        Ok(())
107    }
108
109    /// Resolve a starting offset for **every assigned partition**, so the
110    /// persisted bookmark records partitions that produced no message this run
111    /// — otherwise such a partition is absent on resume and resets to
112    /// `auto.offset.reset` (default `latest`), silently skipping any records
113    /// that arrived in the meantime (the H9 fix).
114    ///
115    /// For a partition that delivered messages, `position()` reports a concrete
116    /// next-offset (cheap, local). For one that produced nothing, `position()`
117    /// reports `Offset::Invalid` (librdkafka only tracks *consumed* offsets), so
118    /// we fall back to its watermark via `fetch_watermarks`: the **low**
119    /// watermark under `earliest`, the **high** watermark under `latest` — i.e.
120    /// exactly where the consumer is positioned for that reset policy. Those
121    /// watermark lookups are a broker round-trip, so each empty partition's
122    /// floor is resolved once and cached.
123    async fn resolve_assigned_offsets(&self) -> Vec<PartitionOffset> {
124        let assigned = match self.consumer.assignment() {
125            Ok(tpl) => tpl,
126            Err(e) => {
127                tracing::warn!(error = %e, "kafka source: assignment() failed; bookmark falls back to consumed/carry-forward offsets");
128                return Vec::new();
129            }
130        };
131        let positions: HashMap<(String, i32), rdkafka::Offset> = self
132            .consumer
133            .position()
134            .map(|tpl| {
135                tpl.elements()
136                    .iter()
137                    .map(|e| ((e.topic().to_string(), e.partition()), e.offset()))
138                    .collect()
139            })
140            .unwrap_or_default();
141
142        let mut out: Vec<PartitionOffset> = Vec::new();
143        let mut need_watermark: Vec<(String, i32)> = Vec::new();
144        {
145            let cache = self.assigned_floor.lock().ok();
146            for elem in assigned.elements() {
147                let key = (elem.topic().to_string(), elem.partition());
148                match positions.get(&key) {
149                    // A delivered partition: its concrete next-offset.
150                    Some(rdkafka::Offset::Offset(n)) => out.push(PartitionOffset {
151                        topic: key.0,
152                        partition: key.1,
153                        offset: *n,
154                    }),
155                    // No concrete position → use a cached watermark floor, or
156                    // schedule a lookup for it.
157                    _ => match cache.as_ref().and_then(|c| c.get(&key)) {
158                        Some(&floor) => out.push(PartitionOffset {
159                            topic: key.0,
160                            partition: key.1,
161                            offset: floor,
162                        }),
163                        None => need_watermark.push(key),
164                    },
165                }
166            }
167        }
168
169        if !need_watermark.is_empty() {
170            let earliest = matches!(
171                self.config.auto_offset_reset,
172                crate::config::OffsetReset::Earliest
173            );
174            let consumer = Arc::clone(&self.consumer);
175            let to_fetch = need_watermark.clone();
176            // `fetch_watermarks` is a blocking librdkafka broker call — run it
177            // off the async runtime.
178            let resolved = tokio::task::spawn_blocking(move || {
179                to_fetch
180                    .into_iter()
181                    .filter_map(|(topic, partition)| {
182                        consumer
183                            .fetch_watermarks(&topic, partition, Duration::from_secs(5))
184                            .ok()
185                            .map(|(low, high)| {
186                                (topic, partition, if earliest { low } else { high })
187                            })
188                    })
189                    .collect::<Vec<_>>()
190            })
191            .await
192            .unwrap_or_default();
193
194            if let Ok(mut cache) = self.assigned_floor.lock() {
195                for (topic, partition, floor) in resolved {
196                    cache.insert((topic.clone(), partition), floor);
197                    out.push(PartitionOffset {
198                        topic,
199                        partition,
200                        offset: floor,
201                    });
202                }
203            }
204        }
205        out
206    }
207
208    /// The start bookmark applied via [`apply_start_bookmark`], retained for
209    /// carry-forward (cloned, not consumed). `None` on a fresh run.
210    fn start_bookmark(&self) -> Option<Bookmark> {
211        self.context
212            .start_offsets
213            .lock()
214            .ok()
215            .and_then(|g| g.clone())
216    }
217
218    /// Build the bookmark to persist from the offsets consumed this page/run,
219    /// merging in the assigned-partition offsets and the carry-forward start
220    /// bookmark. Returns `None` only when there is nothing at all to record (no
221    /// partition assigned, nothing consumed, no prior state).
222    async fn build_bookmark(
223        &self,
224        consumed: &HashMap<(String, i32), i64>,
225    ) -> Result<Option<Value>, FaucetError> {
226        let assigned = self.resolve_assigned_offsets().await;
227        let merged = Bookmark::merged(self.start_bookmark().as_ref(), &assigned, consumed);
228        if merged.partition_offsets.is_empty() {
229            Ok(None)
230        } else {
231            Ok(Some(merged.to_value()?))
232        }
233    }
234
235    /// Whether a cluster coordinator applied a group-member shard (Mode B,
236    /// #261) — i.e. this consumer is one of N cooperating members of the group.
237    fn member_mode(&self) -> bool {
238        self.context.member_mode.load(Ordering::Acquire)
239    }
240
241    /// Group-member mode only: commit `offsets` (the cumulative
242    /// `(topic, partition) -> next_offset` map of pages that are already
243    /// durable — written to the sink and bookmark-persisted) to the consumer
244    /// group, so a partition that migrates to another member on rebalance
245    /// resumes from the last durable position instead of `auto.offset.reset`.
246    ///
247    /// Only currently-assigned partitions are committed — committing a stale
248    /// offset for a partition that migrated away mid-page would regress the
249    /// new owner's commit. Best-effort by design (warn on error): offsets are
250    /// cumulative, so a failed commit is retried at the next durable boundary
251    /// and the cost of a lost commit is a bounded re-read (at-least-once),
252    /// never data loss.
253    ///
254    /// Mid-run commits are `CommitMode::Async` (no broker round-trip on the
255    /// hot path). The `terminal` end-of-stream commit is synchronous (off the
256    /// async runtime) so the group's next member sees the final position even
257    /// when the process exits right after the run — the run-end handoff case.
258    async fn commit_durable(&self, offsets: &HashMap<(String, i32), i64>, terminal: bool) {
259        if !self.member_mode() || offsets.is_empty() {
260            return;
261        }
262        let assigned: HashSet<(String, i32)> = match self.consumer.assignment() {
263            Ok(tpl) => tpl
264                .elements()
265                .iter()
266                .map(|e| (e.topic().to_string(), e.partition()))
267                .collect(),
268            Err(e) => {
269                tracing::warn!(error = %e, "kafka member mode: assignment() failed; skipping offset commit");
270                return;
271            }
272        };
273        let list = commit_list(offsets, &assigned);
274        if list.is_empty() {
275            return;
276        }
277        let mut tpl = TopicPartitionList::with_capacity(list.len());
278        for (topic, partition, offset) in &list {
279            if let Err(e) = tpl.add_partition_offset(topic, *partition, Offset::Offset(*offset)) {
280                tracing::warn!(
281                    topic,
282                    partition,
283                    offset,
284                    error = %e,
285                    "kafka member mode: building commit list failed; skipping offset commit"
286                );
287                return;
288            }
289        }
290        if terminal {
291            // `commit(…, Sync)` is a blocking broker round-trip — run it off
292            // the async runtime.
293            let consumer = Arc::clone(&self.consumer);
294            match tokio::task::spawn_blocking(move || consumer.commit(&tpl, CommitMode::Sync)).await
295            {
296                Ok(Ok(())) => {}
297                Ok(Err(e)) => tracing::warn!(
298                    error = %e,
299                    "kafka member mode: terminal offset commit failed; \
300                     the group's next member may re-read the tail (at-least-once)"
301                ),
302                Err(join_err) => tracing::warn!(
303                    error = %join_err,
304                    "kafka member mode: terminal offset commit task failed"
305                ),
306            }
307        } else if let Err(e) = self.consumer.commit(&tpl, CommitMode::Async) {
308            tracing::warn!(
309                error = %e,
310                "kafka member mode: offset commit failed (retried at the next durable boundary)"
311            );
312        }
313    }
314
315    /// Total partition count across the subscribed topics, used to cap the
316    /// member-shard plan (a member beyond the partition count would sit idle).
317    /// `None` when any topic's metadata cannot be resolved — enumeration then
318    /// trusts the requested member count instead of failing the run.
319    async fn total_partitions(&self) -> Option<usize> {
320        let consumer = Arc::clone(&self.consumer);
321        let topics = self.config.topics.clone();
322        // `fetch_metadata` is a blocking librdkafka broker call — run it off
323        // the async runtime.
324        tokio::task::spawn_blocking(move || {
325            let mut total = 0usize;
326            for topic in &topics {
327                match consumer.fetch_metadata(Some(topic), Duration::from_secs(5)) {
328                    Ok(md) => {
329                        let n: usize = md
330                            .topics()
331                            .iter()
332                            .filter(|t| t.name() == topic)
333                            .map(|t| t.partitions().len())
334                            .sum();
335                        if n == 0 {
336                            tracing::warn!(
337                                topic,
338                                "kafka enumerate_shards: topic has no partitions in metadata; \
339                                 not capping the member count"
340                            );
341                            return None;
342                        }
343                        total += n;
344                    }
345                    Err(e) => {
346                        tracing::warn!(
347                            topic,
348                            error = %e,
349                            "kafka enumerate_shards: metadata fetch failed; \
350                             not capping the member count"
351                        );
352                        return None;
353                    }
354                }
355            }
356            Some(total)
357        })
358        .await
359        .ok()
360        .flatten()
361    }
362
363    async fn message_to_value(
364        &self,
365        msg: &rdkafka::message::BorrowedMessage<'_>,
366    ) -> Result<Value, FaucetError> {
367        let value = decode::decode(
368            msg.payload(),
369            &self.config.value_format,
370            #[cfg(feature = "schema-registry")]
371            self.sr_client.as_ref(),
372        )
373        .await?;
374
375        let key = match &self.config.key_format {
376            Some(fmt) => {
377                decode::decode(
378                    msg.key(),
379                    fmt,
380                    #[cfg(feature = "schema-registry")]
381                    self.sr_client.as_ref(),
382                )
383                .await?
384            }
385            None => match msg.key() {
386                Some(bytes) => Value::String(
387                    std::str::from_utf8(bytes)
388                        .map_err(|e| FaucetError::Source(format!("kafka key utf-8: {e}")))?
389                        .to_string(),
390                ),
391                None => Value::Null,
392            },
393        };
394
395        let mut headers_obj = Map::new();
396        if let Some(headers) = msg.headers() {
397            for h in headers.iter() {
398                if let Some(value_bytes) = h.value {
399                    if let Ok(s) = std::str::from_utf8(value_bytes) {
400                        headers_obj.insert(h.key.to_string(), Value::String(s.to_string()));
401                    } else {
402                        let encoded = base64::engine::general_purpose::STANDARD.encode(value_bytes);
403                        headers_obj.insert(h.key.to_string(), Value::String(encoded));
404                    }
405                }
406            }
407        }
408
409        Ok(json!({
410            "key": key,
411            "value": value,
412            "topic": msg.topic(),
413            "partition": msg.partition(),
414            "offset": msg.offset(),
415            "timestamp": msg.timestamp().to_millis().unwrap_or(0),
416            "headers": Value::Object(headers_obj),
417        }))
418    }
419}
420
421#[cfg(feature = "schema-registry")]
422fn build_sr_client(
423    value_format: &KafkaValueFormat,
424    key_format: Option<&KafkaValueFormat>,
425) -> Result<Option<SchemaRegistryClient>, FaucetError> {
426    fn extract_cfg(f: &KafkaValueFormat) -> Option<&faucet_common_kafka::SchemaRegistryConfig> {
427        match f {
428            KafkaValueFormat::ConfluentAvro { schema_registry }
429            | KafkaValueFormat::ConfluentProtobuf { schema_registry } => Some(schema_registry),
430            KafkaValueFormat::ConfluentJsonSchema {
431                schema_registry, ..
432            } => Some(schema_registry),
433            _ => None,
434        }
435    }
436    let cfg = extract_cfg(value_format).or_else(|| key_format.and_then(extract_cfg));
437    cfg.map(SchemaRegistryClient::new).transpose()
438}
439
440#[async_trait]
441impl Source for KafkaSource {
442    async fn fetch_with_context(
443        &self,
444        context: &HashMap<String, Value>,
445    ) -> Result<Vec<Value>, FaucetError> {
446        let (records, _bookmark) = self.fetch_with_context_incremental(context).await?;
447        Ok(records)
448    }
449
450    async fn fetch_with_context_incremental(
451        &self,
452        _context: &HashMap<String, Value>,
453    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
454        let mut records: Vec<Value> = Vec::new();
455        let mut pending_offsets: HashMap<(String, i32), i64> = HashMap::new();
456        let mut last_message_at = Instant::now();
457        let max_messages = self.config.max_messages.unwrap_or(usize::MAX);
458        let idle_timeout = self.config.idle_timeout;
459
460        loop {
461            // Surface any error raised by the rebalance callback (e.g. a
462            // failed seek). Done before the next poll so we don't process
463            // additional messages after a bookmark application failure.
464            self.check_callback_error()?;
465
466            let idle_deadline = idle_timeout.map(|t| last_message_at + t);
467            let poll_budget = match idle_deadline {
468                Some(deadline) => deadline
469                    .checked_duration_since(Instant::now())
470                    .unwrap_or(Duration::ZERO),
471                None => self.config.poll_timeout,
472            };
473
474            tokio::select! {
475                biased;
476                _ = tokio::signal::ctrl_c() => {
477                    tracing::info!("kafka source: ctrl_c received, stopping cleanly");
478                    break;
479                }
480                recv = tokio::time::timeout(poll_budget, self.consumer.recv()) => {
481                    match recv {
482                        Ok(Ok(msg)) => {
483                            match self.message_to_value(&msg).await {
484                                Ok(record) => {
485                                    pending_offsets.insert(
486                                        (msg.topic().to_string(), msg.partition()),
487                                        msg.offset() + 1,
488                                    );
489                                    records.push(record);
490                                    last_message_at = Instant::now();
491                                    if records.len() >= max_messages {
492                                        break;
493                                    }
494                                }
495                                Err(e) => match self.config.on_decode_error {
496                                    OnDecodeError::Skip => {
497                                        tracing::warn!(error = %e, "kafka source: decode failed, skipping message");
498                                    }
499                                    OnDecodeError::Fail => return Err(e),
500                                },
501                            }
502                        }
503                        Ok(Err(e)) => {
504                            return Err(FaucetError::Source(format!("kafka recv: {e}")));
505                        }
506                        Err(_timeout) => {
507                            if let Some(deadline) = idle_deadline
508                                && Instant::now() >= deadline
509                            {
510                                tracing::debug!("kafka source: idle_timeout reached, stopping");
511                                break;
512                            }
513                        }
514                    }
515                }
516            }
517        }
518
519        let bookmark_value = self.build_bookmark(&pending_offsets).await?;
520        Ok((records, bookmark_value))
521    }
522
523    /// Stream Kafka messages page-by-page. Mirrors the
524    /// [`fetch_with_context_incremental`](Self::fetch_with_context_incremental)
525    /// poll loop but emits a [`StreamPage`] each time the in-memory buffer
526    /// reaches [`KafkaSourceConfig::batch_size`] (or whenever the idle window
527    /// flushes a partially-filled buffer), and continues polling until the
528    /// configured `max_messages` / `idle_timeout` termination conditions are
529    /// hit.
530    ///
531    /// The trait-level `batch_size` argument is intentionally ignored in
532    /// favour of the config field — the config is the user-facing knob the
533    /// README documents and routing the pipeline-supplied hint through it
534    /// would silently override an explicit config value.
535    ///
536    /// **Per-page bookmark:** each yielded page carries a snapshot of the
537    /// cumulative `(topic, partition) -> next_offset` map seen so far. The
538    /// streaming pipeline persists this via the configured `StateStore`
539    /// *after* the sink confirms the write, giving at-least-once delivery
540    /// with per-page durability — a crash between pages re-reads only the
541    /// uncommitted page on resume (the rebalance callback seeds the assigned
542    /// partitions with the bookmarked offsets before any fetch happens).
543    ///
544    /// **`batch_size = 0`:** drains the entire run window (until
545    /// `max_messages` or `idle_timeout` fires) into a single page. This
546    /// negates the streaming benefit; prefer a finite `batch_size` in
547    /// production so the state store advances with each successful sink
548    /// write.
549    fn stream_pages<'a>(
550        &'a self,
551        _context: &'a HashMap<String, Value>,
552        _batch_size: usize,
553    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
554        let batch_size = self.config.batch_size;
555        let max_messages = self.config.max_messages.unwrap_or(usize::MAX);
556        let idle_timeout = self.config.idle_timeout;
557        let poll_timeout = self.config.poll_timeout;
558        let on_decode_error = self.config.on_decode_error;
559
560        // batch_size == 0 means "drain entire run window into one page" —
561        // effectively no per-page flush boundary. Otherwise the page flushes
562        // as soon as it accumulates `batch_size` messages.
563        let page_chunk = if batch_size == 0 {
564            usize::MAX
565        } else {
566            batch_size
567        };
568        let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
569
570        Box::pin(async_stream::try_stream! {
571            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
572            let mut pending_offsets: HashMap<(String, i32), i64> = HashMap::new();
573            let mut last_message_at = Instant::now();
574            let mut total: usize = 0;
575            // Group-member mode (#261): offsets of the last yielded page,
576            // committed to the consumer group once that page is DURABLE. The
577            // generator resumes after a `yield` only when the pipeline polls
578            // the next page — which happens after it has written the previous
579            // page to the sink and persisted its bookmark — so committing at
580            // resume time never advances the group past unwritten data.
581            let mut to_commit: Option<HashMap<(String, i32), i64>> = None;
582
583            loop {
584                if let Some(durable) = to_commit.take() {
585                    self.commit_durable(&durable, false).await;
586                }
587
588                // Surface any error raised by the rebalance callback (e.g. a
589                // failed seek) before processing the next poll batch.
590                self.check_callback_error()?;
591
592                let idle_deadline = idle_timeout.map(|t| last_message_at + t);
593                let poll_budget = match idle_deadline {
594                    Some(deadline) => deadline
595                        .checked_duration_since(Instant::now())
596                        .unwrap_or(Duration::ZERO),
597                    None => poll_timeout,
598                };
599
600                // Accumulators set by the select arm — `?` cannot cross the
601                // select's match boundary into the outer `try_stream!`, so we
602                // collect errors and termination flags here and act on them
603                // after the select returns.
604                let mut stop = false;
605                let mut fatal: Option<FaucetError> = None;
606                tokio::select! {
607                    biased;
608                    _ = tokio::signal::ctrl_c() => {
609                        tracing::info!("kafka source: ctrl_c received, stopping cleanly");
610                        stop = true;
611                    }
612                    recv = tokio::time::timeout(poll_budget, self.consumer.recv()) => {
613                        match recv {
614                            Ok(Ok(msg)) => {
615                                match self.message_to_value(&msg).await {
616                                    Ok(record) => {
617                                        pending_offsets.insert(
618                                            (msg.topic().to_string(), msg.partition()),
619                                            msg.offset() + 1,
620                                        );
621                                        buffer.push(record);
622                                        last_message_at = Instant::now();
623                                        total += 1;
624                                        if total >= max_messages {
625                                            stop = true;
626                                        }
627                                    }
628                                    Err(e) => match on_decode_error {
629                                        OnDecodeError::Skip => {
630                                            tracing::warn!(error = %e, "kafka source: decode failed, skipping message");
631                                        }
632                                        OnDecodeError::Fail => fatal = Some(e),
633                                    },
634                                }
635                            }
636                            Ok(Err(e)) => {
637                                fatal = Some(FaucetError::Source(format!("kafka recv: {e}")));
638                            }
639                            Err(_timeout) => {
640                                if let Some(deadline) = idle_deadline
641                                    && Instant::now() >= deadline
642                                {
643                                    tracing::debug!("kafka source: idle_timeout reached, stopping");
644                                    stop = true;
645                                }
646                            }
647                        }
648                    }
649                }
650
651                if let Some(e) = fatal {
652                    Err(e)?;
653                }
654
655                // Yield a full page as soon as the buffer hits `page_chunk`.
656                // Bookmark = cumulative snapshot of pending_offsets after this
657                // page's last message. Snapshot before flushing the buffer so
658                // a crash between yield and the next loop iteration re-reads
659                // only the uncommitted messages, not those already in this
660                // page.
661                if !buffer.is_empty() && buffer.len() >= page_chunk {
662                    let page_records = std::mem::replace(
663                        &mut buffer,
664                        Vec::with_capacity(initial_capacity),
665                    );
666                    let bookmark = self.build_bookmark(&pending_offsets).await?;
667                    let durable_snapshot = pending_offsets.clone();
668                    yield StreamPage { records: page_records, bookmark };
669                    // Resumed ⇒ the page above is durable; commit its offsets
670                    // to the group at the top of the next iteration.
671                    to_commit = Some(durable_snapshot);
672                }
673
674                if stop {
675                    break;
676                }
677            }
678
679            // Flush the trailing buffer (may be empty if the run terminated
680            // exactly on a page boundary). When non-empty, emit one final
681            // page carrying the cumulative bookmark.
682            if !buffer.is_empty() {
683                let bookmark = self.build_bookmark(&pending_offsets).await?;
684                yield StreamPage { records: buffer, bookmark };
685            }
686
687            // This code runs only when the pipeline polls past the final page
688            // — i.e. after every yielded page has been written and its
689            // bookmark persisted — so the whole cumulative offset map is
690            // durable. Committing it hands the group's next member a clean
691            // starting position (covers the tail the mid-loop commit missed).
692            self.commit_durable(&pending_offsets, true).await;
693
694            tracing::info!(
695                messages = total,
696                batch_size,
697                "kafka source: stream complete",
698            );
699        })
700    }
701
702    fn config_schema(&self) -> Value {
703        let schema = schemars::schema_for!(KafkaSourceConfig);
704        serde_json::to_value(&schema).unwrap_or(Value::Null)
705    }
706
707    fn state_key(&self) -> Option<String> {
708        Some(self.state_key_value.clone())
709    }
710
711    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
712        let parsed = Bookmark::from_value(bookmark)?;
713        // `pending_bookmark` is consumed by the rebalance callback to seed the
714        // assigned partitions' starting offsets. `start_offsets` keeps a
715        // retained copy so previously-known partitions that are empty this run
716        // carry their offset forward into the next bookmark (the H9 fix).
717        {
718            let mut guard = self.context.start_offsets.lock().map_err(|e| {
719                FaucetError::State(format!("kafka start_offsets mutex poisoned: {e}"))
720            })?;
721            *guard = Some(parsed.clone());
722        }
723        let mut guard = self.context.pending_bookmark.lock().map_err(|e| {
724            FaucetError::State(format!("kafka pending_bookmark mutex poisoned: {e}"))
725        })?;
726        *guard = Some(parsed);
727        Ok(())
728    }
729
730    fn connector_name(&self) -> &'static str {
731        "kafka"
732    }
733
734    fn dataset_uri(&self) -> String {
735        let broker = self
736            .config
737            .brokers
738            .split(',')
739            .next()
740            .unwrap_or(&self.config.brokers)
741            .trim();
742        format!("kafka://{}?topic={}", broker, self.config.topics.join(","))
743    }
744
745    /// Preflight probe that does **not** consume any message.
746    ///
747    /// The default `Source::check` would call `stream_pages`, which polls for
748    /// a message and would block on an empty topic until the idle/max-message
749    /// terminator fires. Instead we fetch cluster metadata
750    /// (`fetch_metadata(None, timeout)`), which validates broker connectivity +
751    /// auth without consuming or committing anything.
752    ///
753    /// `fetch_metadata` is a blocking librdkafka call, so it runs on a blocking
754    /// thread; the whole probe is additionally bounded by `ctx.timeout`.
755    async fn check(
756        &self,
757        ctx: &faucet_core::check::CheckContext,
758    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
759        use faucet_core::check::{CheckReport, Probe};
760        use rdkafka::util::Timeout;
761
762        let start = std::time::Instant::now();
763        let consumer = Arc::clone(&self.consumer);
764        let rd_timeout = Timeout::After(ctx.timeout);
765
766        // Run the blocking fetch_metadata off the async runtime, bounded by the
767        // same wall-clock budget so an unreachable broker can't hang the probe.
768        let fetch = tokio::task::spawn_blocking(move || {
769            consumer
770                .fetch_metadata(None, rd_timeout)
771                .map(|md| md.brokers().len())
772                .map_err(|e| e.to_string())
773        });
774
775        let probe = match tokio::time::timeout(ctx.timeout, fetch).await {
776            Ok(Ok(Ok(broker_count))) => {
777                tracing::debug!(broker_count, "kafka check: fetched cluster metadata");
778                Probe::pass("metadata", start.elapsed())
779            }
780            Ok(Ok(Err(e))) => Probe::fail_hint(
781                "metadata",
782                start.elapsed(),
783                e,
784                "verify brokers, network reachability, and auth (SASL/TLS) settings",
785            ),
786            Ok(Err(join_err)) => Probe::fail(
787                "metadata",
788                start.elapsed(),
789                format!("metadata fetch task failed: {join_err}"),
790            ),
791            Err(_elapsed) => Probe::fail_hint(
792                "metadata",
793                start.elapsed(),
794                "metadata fetch timed out",
795                "no broker responded within the check timeout",
796            ),
797        };
798        Ok(CheckReport::single(probe))
799    }
800
801    /// The Kafka source is always shardable via **native consumer-group
802    /// assignment** (#261): each shard is a membership slot in the shared
803    /// group, and the broker — not faucet — assigns partitions across the
804    /// members and rebalances on membership change. Sharding only takes
805    /// effect when the cluster coordinator calls `apply_shard`; a plain
806    /// `faucet run` consumes as a single group member, unchanged.
807    fn is_shardable(&self) -> bool {
808        true
809    }
810
811    /// Enumerate `target` group-membership slots, capped at the subscription's
812    /// total partition count (extra members would never be assigned a
813    /// partition). Falls back to the uncapped `target` when topic metadata
814    /// cannot be resolved.
815    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
816        if target <= 1 {
817            return Ok(vec![ShardSpec::whole()]);
818        }
819        let cap = self.total_partitions().await;
820        Ok(plan_member_shards(target, cap))
821    }
822
823    /// Enter group-member mode for one membership slot (or leave it, for the
824    /// whole-dataset shard). In member mode the source additionally:
825    ///
826    /// - **commits offsets to the consumer group at durable page boundaries**
827    ///   (see [`stream_pages`](Self::stream_pages)), so partitions that
828    ///   migrate between members resume from the last durably-written
829    ///   position;
830    /// - **defers bookmark seeks to the group's committed offsets** whenever
831    ///   those are ahead (another member may have advanced a partition past
832    ///   this member's bookmark).
833    ///
834    /// Only the streaming path (`stream_pages`, which every `Pipeline` run
835    /// drives) commits; the batch `fetch_all` path never commits because its
836    /// records are not durable until after the fetch returns.
837    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
838        let parsed = parse_member_shard(shard)?;
839        self.context
840            .member_mode
841            .store(parsed.is_some(), Ordering::Release);
842        *self
843            .member_shard
844            .lock()
845            .map_err(|e| FaucetError::State(format!("kafka member_shard mutex poisoned: {e}")))? =
846            parsed;
847        if let Some(m) = parsed {
848            tracing::info!(
849                member = m.member,
850                members = m.members,
851                group = %self.config.group_id,
852                "kafka source: joining the consumer group as one of N cooperating members (Mode B)"
853            );
854        }
855        Ok(())
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862    use crate::config::OffsetReset;
863    use std::collections::BTreeMap;
864
865    /// Build a source pointing at an unreachable broker. rdkafka client
866    /// construction and `subscribe()` are local operations (no broker
867    /// round-trip), so this constructs fine offline — only actual
868    /// polls/metadata fetches would fail.
869    async fn offline_source() -> KafkaSource {
870        let config = KafkaSourceConfig {
871            brokers: "127.0.0.1:1".into(),
872            topics: vec!["orders".into()],
873            group_id: "g".into(),
874            auth: faucet_common_kafka::KafkaAuth::None,
875            value_format: faucet_common_kafka::KafkaValueFormat::Json,
876            key_format: None,
877            auto_offset_reset: OffsetReset::Latest,
878            max_messages: Some(1),
879            idle_timeout: None,
880            poll_timeout: Duration::from_secs(1),
881            session_timeout: Duration::from_secs(30),
882            on_decode_error: OnDecodeError::Fail,
883            extra_client_config: BTreeMap::new(),
884            batch_size: 10,
885        };
886        KafkaSource::new(config)
887            .await
888            .expect("offline construction")
889    }
890
891    #[tokio::test]
892    async fn source_is_shardable() {
893        assert!(offline_source().await.is_shardable());
894    }
895
896    #[tokio::test]
897    async fn apply_shard_enters_and_leaves_member_mode() {
898        let source = offline_source().await;
899        assert!(!source.member_mode(), "plain run starts out of member mode");
900
901        let member = ShardSpec::new("1", serde_json::json!({ "members": 3, "member": 1 }));
902        source.apply_shard(&member).await.unwrap();
903        assert!(source.member_mode());
904        assert_eq!(
905            *source.member_shard.lock().unwrap(),
906            Some(MemberShard {
907                members: 3,
908                member: 1
909            })
910        );
911
912        // The whole-dataset shard clears member mode (plain single consumer).
913        source.apply_shard(&ShardSpec::whole()).await.unwrap();
914        assert!(!source.member_mode());
915        assert_eq!(*source.member_shard.lock().unwrap(), None);
916    }
917
918    #[tokio::test]
919    async fn apply_shard_rejects_malformed_descriptor() {
920        let source = offline_source().await;
921        let err = source
922            .apply_shard(&ShardSpec::new("0", serde_json::json!({ "member": 0 })))
923            .await
924            .unwrap_err();
925        assert!(err.to_string().contains("kafka"), "got: {err}");
926        assert!(
927            !source.member_mode(),
928            "a rejected shard must not flip modes"
929        );
930    }
931
932    #[tokio::test]
933    async fn enumerate_shards_target_one_is_whole() {
934        // target <= 1 short-circuits before any metadata fetch, so this is
935        // fast offline.
936        let source = offline_source().await;
937        let shards = source.enumerate_shards(1).await.unwrap();
938        assert_eq!(shards.len(), 1);
939        assert!(shards[0].is_whole());
940    }
941
942    #[tokio::test]
943    async fn enumerate_shards_uncapped_when_metadata_unreachable() {
944        // fetch_metadata against the unreachable broker fails within its
945        // bounded budget; enumeration falls back to the requested member
946        // count (uncapped) instead of failing the run.
947        let source = offline_source().await;
948        let shards = source.enumerate_shards(3).await.unwrap();
949        assert_eq!(shards.len(), 3);
950        assert_eq!(shards[0].descriptor["members"], 3);
951        assert_eq!(shards[2].descriptor["member"], 2);
952    }
953
954    #[tokio::test]
955    async fn commit_durable_with_no_assignment_commits_nothing() {
956        // Member mode + a non-empty offset map, but the consumer owns no
957        // partitions (it never polled): the assigned-partition filter drops
958        // everything and no commit is attempted — which would otherwise
959        // block against the unreachable broker.
960        let source = offline_source().await;
961        source
962            .apply_shard(&ShardSpec::new(
963                "0",
964                serde_json::json!({ "members": 2, "member": 0 }),
965            ))
966            .await
967            .unwrap();
968        let mut offsets = HashMap::new();
969        offsets.insert(("orders".to_string(), 0), 10i64);
970        source.commit_durable(&offsets, true).await;
971    }
972
973    #[tokio::test]
974    async fn commit_durable_is_a_noop_outside_member_mode() {
975        // Must not touch the (unreachable) broker: returns before any
976        // assignment/commit call when member mode is off or offsets are empty.
977        let source = offline_source().await;
978        let mut offsets = HashMap::new();
979        offsets.insert(("orders".to_string(), 0), 10i64);
980        source.commit_durable(&offsets, false).await; // member mode off
981        source
982            .apply_shard(&ShardSpec::new(
983                "0",
984                serde_json::json!({ "members": 2, "member": 0 }),
985            ))
986            .await
987            .unwrap();
988        source.commit_durable(&HashMap::new(), true).await; // empty offsets
989    }
990
991    #[test]
992    fn dataset_uri_single_broker_single_topic() {
993        let brokers = "kafka.example.com:9092";
994        let topics = ["orders"];
995        let broker = brokers.split(',').next().unwrap_or(brokers).trim();
996        let uri = format!("kafka://{}?topic={}", broker, topics.join(","));
997        assert_eq!(uri, "kafka://kafka.example.com:9092?topic=orders");
998    }
999
1000    #[test]
1001    fn dataset_uri_multi_broker_uses_first() {
1002        let brokers = "b1:9092,b2:9092,b3:9092";
1003        let topics = ["events", "logs"];
1004        let broker = brokers.split(',').next().unwrap_or(brokers).trim();
1005        let uri = format!("kafka://{}?topic={}", broker, topics.join(","));
1006        assert_eq!(uri, "kafka://b1:9092?topic=events,logs");
1007    }
1008
1009    #[cfg(feature = "schema-registry")]
1010    mod sr_client {
1011        use crate::stream::build_sr_client;
1012        use faucet_common_kafka::{KafkaValueFormat, SchemaRegistryConfig};
1013
1014        #[test]
1015        fn build_sr_client_none_for_plain_formats() {
1016            assert!(
1017                build_sr_client(&KafkaValueFormat::Json, None)
1018                    .unwrap()
1019                    .is_none()
1020            );
1021            assert!(
1022                build_sr_client(&KafkaValueFormat::RawString, Some(&KafkaValueFormat::Bytes))
1023                    .unwrap()
1024                    .is_none()
1025            );
1026        }
1027
1028        #[test]
1029        fn build_sr_client_some_for_confluent_avro_value() {
1030            let format = KafkaValueFormat::ConfluentAvro {
1031                schema_registry: SchemaRegistryConfig::new("http://localhost:8081"),
1032            };
1033            assert!(build_sr_client(&format, None).unwrap().is_some());
1034        }
1035
1036        #[test]
1037        fn build_sr_client_some_for_confluent_protobuf_value() {
1038            let format = KafkaValueFormat::ConfluentProtobuf {
1039                schema_registry: SchemaRegistryConfig::new("http://localhost:8081"),
1040            };
1041            assert!(build_sr_client(&format, None).unwrap().is_some());
1042        }
1043
1044        #[test]
1045        fn build_sr_client_some_for_confluent_json_schema_value() {
1046            let format = KafkaValueFormat::ConfluentJsonSchema {
1047                schema_registry: SchemaRegistryConfig::new("http://localhost:8081"),
1048                validate: true,
1049            };
1050            assert!(build_sr_client(&format, None).unwrap().is_some());
1051        }
1052
1053        #[test]
1054        fn build_sr_client_falls_back_to_key_format() {
1055            let key = KafkaValueFormat::ConfluentAvro {
1056                schema_registry: SchemaRegistryConfig::new("http://localhost:8081"),
1057            };
1058            assert!(
1059                build_sr_client(&KafkaValueFormat::Json, Some(&key))
1060                    .unwrap()
1061                    .is_some()
1062            );
1063        }
1064
1065        #[test]
1066        fn build_sr_client_propagates_invalid_url() {
1067            let format = KafkaValueFormat::ConfluentAvro {
1068                schema_registry: SchemaRegistryConfig::new("not-a-url"),
1069            };
1070            assert!(build_sr_client(&format, None).is_err());
1071        }
1072    }
1073}