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    /// Kafka partitions are immutable, ordered logs and every emitted page
731    /// carries a complete per-partition next-offset bookmark, so resuming from
732    /// any bookmark continues the record stream at exactly that position — no
733    /// record before it is re-emitted, none after it is skipped. Page
734    /// *boundaries* on replay may differ (idle-timeout cuts are timing-
735    /// dependent), which is why the pipeline's atomic-watermark mechanism
736    /// re-anchors from the bookmark embedded in the sink's committed token
737    /// rather than skipping replayed pages by count. In plain (non-member)
738    /// mode the source never commits consumer-group offsets, so the broker's
739    /// group position can never run ahead of the durable bookmark.
740    fn supports_exactly_once(&self) -> bool {
741        true
742    }
743
744    fn connector_name(&self) -> &'static str {
745        "kafka"
746    }
747
748    fn dataset_uri(&self) -> String {
749        let broker = self
750            .config
751            .brokers
752            .split(',')
753            .next()
754            .unwrap_or(&self.config.brokers)
755            .trim();
756        format!("kafka://{}?topic={}", broker, self.config.topics.join(","))
757    }
758
759    /// Preflight probe that does **not** consume any message.
760    ///
761    /// The default `Source::check` would call `stream_pages`, which polls for
762    /// a message and would block on an empty topic until the idle/max-message
763    /// terminator fires. Instead we fetch cluster metadata
764    /// (`fetch_metadata(None, timeout)`), which validates broker connectivity +
765    /// auth without consuming or committing anything.
766    ///
767    /// `fetch_metadata` is a blocking librdkafka call, so it runs on a blocking
768    /// thread; the whole probe is additionally bounded by `ctx.timeout`.
769    async fn check(
770        &self,
771        ctx: &faucet_core::check::CheckContext,
772    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
773        use faucet_core::check::{CheckReport, Probe};
774        use rdkafka::util::Timeout;
775
776        let start = std::time::Instant::now();
777        let consumer = Arc::clone(&self.consumer);
778        let rd_timeout = Timeout::After(ctx.timeout);
779
780        // Run the blocking fetch_metadata off the async runtime, bounded by the
781        // same wall-clock budget so an unreachable broker can't hang the probe.
782        let fetch = tokio::task::spawn_blocking(move || {
783            consumer
784                .fetch_metadata(None, rd_timeout)
785                .map(|md| md.brokers().len())
786                .map_err(|e| e.to_string())
787        });
788
789        let probe = match tokio::time::timeout(ctx.timeout, fetch).await {
790            Ok(Ok(Ok(broker_count))) => {
791                tracing::debug!(broker_count, "kafka check: fetched cluster metadata");
792                Probe::pass("metadata", start.elapsed())
793            }
794            Ok(Ok(Err(e))) => Probe::fail_hint(
795                "metadata",
796                start.elapsed(),
797                e,
798                "verify brokers, network reachability, and auth (SASL/TLS) settings",
799            ),
800            Ok(Err(join_err)) => Probe::fail(
801                "metadata",
802                start.elapsed(),
803                format!("metadata fetch task failed: {join_err}"),
804            ),
805            Err(_elapsed) => Probe::fail_hint(
806                "metadata",
807                start.elapsed(),
808                "metadata fetch timed out",
809                "no broker responded within the check timeout",
810            ),
811        };
812        Ok(CheckReport::single(probe))
813    }
814
815    /// The Kafka source is always shardable via **native consumer-group
816    /// assignment** (#261): each shard is a membership slot in the shared
817    /// group, and the broker — not faucet — assigns partitions across the
818    /// members and rebalances on membership change. Sharding only takes
819    /// effect when the cluster coordinator calls `apply_shard`; a plain
820    /// `faucet run` consumes as a single group member, unchanged.
821    fn is_shardable(&self) -> bool {
822        true
823    }
824
825    /// Enumerate `target` group-membership slots, capped at the subscription's
826    /// total partition count (extra members would never be assigned a
827    /// partition). Falls back to the uncapped `target` when topic metadata
828    /// cannot be resolved.
829    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
830        if target <= 1 {
831            return Ok(vec![ShardSpec::whole()]);
832        }
833        let cap = self.total_partitions().await;
834        Ok(plan_member_shards(target, cap))
835    }
836
837    /// Enter group-member mode for one membership slot (or leave it, for the
838    /// whole-dataset shard). In member mode the source additionally:
839    ///
840    /// - **commits offsets to the consumer group at durable page boundaries**
841    ///   (see [`stream_pages`](Self::stream_pages)), so partitions that
842    ///   migrate between members resume from the last durably-written
843    ///   position;
844    /// - **defers bookmark seeks to the group's committed offsets** whenever
845    ///   those are ahead (another member may have advanced a partition past
846    ///   this member's bookmark).
847    ///
848    /// Only the streaming path (`stream_pages`, which every `Pipeline` run
849    /// drives) commits; the batch `fetch_all` path never commits because its
850    /// records are not durable until after the fetch returns.
851    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
852        let parsed = parse_member_shard(shard)?;
853        self.context
854            .member_mode
855            .store(parsed.is_some(), Ordering::Release);
856        *self
857            .member_shard
858            .lock()
859            .map_err(|e| FaucetError::State(format!("kafka member_shard mutex poisoned: {e}")))? =
860            parsed;
861        if let Some(m) = parsed {
862            tracing::info!(
863                member = m.member,
864                members = m.members,
865                group = %self.config.group_id,
866                "kafka source: joining the consumer group as one of N cooperating members (Mode B)"
867            );
868        }
869        Ok(())
870    }
871}
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876    use crate::config::OffsetReset;
877    use std::collections::BTreeMap;
878
879    /// Build a source pointing at an unreachable broker. rdkafka client
880    /// construction and `subscribe()` are local operations (no broker
881    /// round-trip), so this constructs fine offline — only actual
882    /// polls/metadata fetches would fail.
883    async fn offline_source() -> KafkaSource {
884        let config = KafkaSourceConfig {
885            brokers: "127.0.0.1:1".into(),
886            topics: vec!["orders".into()],
887            group_id: "g".into(),
888            auth: faucet_common_kafka::KafkaAuth::None,
889            value_format: faucet_common_kafka::KafkaValueFormat::Json,
890            key_format: None,
891            auto_offset_reset: OffsetReset::Latest,
892            max_messages: Some(1),
893            idle_timeout: None,
894            poll_timeout: Duration::from_secs(1),
895            session_timeout: Duration::from_secs(30),
896            on_decode_error: OnDecodeError::Fail,
897            extra_client_config: BTreeMap::new(),
898            batch_size: 10,
899        };
900        KafkaSource::new(config)
901            .await
902            .expect("offline construction")
903    }
904
905    #[tokio::test]
906    async fn source_is_shardable() {
907        assert!(offline_source().await.is_shardable());
908    }
909
910    #[tokio::test]
911    async fn apply_shard_enters_and_leaves_member_mode() {
912        let source = offline_source().await;
913        assert!(!source.member_mode(), "plain run starts out of member mode");
914
915        let member = ShardSpec::new("1", serde_json::json!({ "members": 3, "member": 1 }));
916        source.apply_shard(&member).await.unwrap();
917        assert!(source.member_mode());
918        assert_eq!(
919            *source.member_shard.lock().unwrap(),
920            Some(MemberShard {
921                members: 3,
922                member: 1
923            })
924        );
925
926        // The whole-dataset shard clears member mode (plain single consumer).
927        source.apply_shard(&ShardSpec::whole()).await.unwrap();
928        assert!(!source.member_mode());
929        assert_eq!(*source.member_shard.lock().unwrap(), None);
930    }
931
932    #[tokio::test]
933    async fn apply_shard_rejects_malformed_descriptor() {
934        let source = offline_source().await;
935        let err = source
936            .apply_shard(&ShardSpec::new("0", serde_json::json!({ "member": 0 })))
937            .await
938            .unwrap_err();
939        assert!(err.to_string().contains("kafka"), "got: {err}");
940        assert!(
941            !source.member_mode(),
942            "a rejected shard must not flip modes"
943        );
944    }
945
946    #[tokio::test]
947    async fn enumerate_shards_target_one_is_whole() {
948        // target <= 1 short-circuits before any metadata fetch, so this is
949        // fast offline.
950        let source = offline_source().await;
951        let shards = source.enumerate_shards(1).await.unwrap();
952        assert_eq!(shards.len(), 1);
953        assert!(shards[0].is_whole());
954    }
955
956    #[tokio::test]
957    async fn enumerate_shards_uncapped_when_metadata_unreachable() {
958        // fetch_metadata against the unreachable broker fails within its
959        // bounded budget; enumeration falls back to the requested member
960        // count (uncapped) instead of failing the run.
961        let source = offline_source().await;
962        let shards = source.enumerate_shards(3).await.unwrap();
963        assert_eq!(shards.len(), 3);
964        assert_eq!(shards[0].descriptor["members"], 3);
965        assert_eq!(shards[2].descriptor["member"], 2);
966    }
967
968    #[tokio::test]
969    async fn commit_durable_with_no_assignment_commits_nothing() {
970        // Member mode + a non-empty offset map, but the consumer owns no
971        // partitions (it never polled): the assigned-partition filter drops
972        // everything and no commit is attempted — which would otherwise
973        // block against the unreachable broker.
974        let source = offline_source().await;
975        source
976            .apply_shard(&ShardSpec::new(
977                "0",
978                serde_json::json!({ "members": 2, "member": 0 }),
979            ))
980            .await
981            .unwrap();
982        let mut offsets = HashMap::new();
983        offsets.insert(("orders".to_string(), 0), 10i64);
984        source.commit_durable(&offsets, true).await;
985    }
986
987    #[tokio::test]
988    async fn commit_durable_is_a_noop_outside_member_mode() {
989        // Must not touch the (unreachable) broker: returns before any
990        // assignment/commit call when member mode is off or offsets are empty.
991        let source = offline_source().await;
992        let mut offsets = HashMap::new();
993        offsets.insert(("orders".to_string(), 0), 10i64);
994        source.commit_durable(&offsets, false).await; // member mode off
995        source
996            .apply_shard(&ShardSpec::new(
997                "0",
998                serde_json::json!({ "members": 2, "member": 0 }),
999            ))
1000            .await
1001            .unwrap();
1002        source.commit_durable(&HashMap::new(), true).await; // empty offsets
1003    }
1004
1005    #[test]
1006    fn dataset_uri_single_broker_single_topic() {
1007        let brokers = "kafka.example.com:9092";
1008        let topics = ["orders"];
1009        let broker = brokers.split(',').next().unwrap_or(brokers).trim();
1010        let uri = format!("kafka://{}?topic={}", broker, topics.join(","));
1011        assert_eq!(uri, "kafka://kafka.example.com:9092?topic=orders");
1012    }
1013
1014    #[test]
1015    fn dataset_uri_multi_broker_uses_first() {
1016        let brokers = "b1:9092,b2:9092,b3:9092";
1017        let topics = ["events", "logs"];
1018        let broker = brokers.split(',').next().unwrap_or(brokers).trim();
1019        let uri = format!("kafka://{}?topic={}", broker, topics.join(","));
1020        assert_eq!(uri, "kafka://b1:9092?topic=events,logs");
1021    }
1022
1023    #[cfg(feature = "schema-registry")]
1024    mod sr_client {
1025        use crate::stream::build_sr_client;
1026        use faucet_common_kafka::{KafkaValueFormat, SchemaRegistryConfig};
1027
1028        #[test]
1029        fn build_sr_client_none_for_plain_formats() {
1030            assert!(
1031                build_sr_client(&KafkaValueFormat::Json, None)
1032                    .unwrap()
1033                    .is_none()
1034            );
1035            assert!(
1036                build_sr_client(&KafkaValueFormat::RawString, Some(&KafkaValueFormat::Bytes))
1037                    .unwrap()
1038                    .is_none()
1039            );
1040        }
1041
1042        #[test]
1043        fn build_sr_client_some_for_confluent_avro_value() {
1044            let format = KafkaValueFormat::ConfluentAvro {
1045                schema_registry: SchemaRegistryConfig::new("http://localhost:8081"),
1046            };
1047            assert!(build_sr_client(&format, None).unwrap().is_some());
1048        }
1049
1050        #[test]
1051        fn build_sr_client_some_for_confluent_protobuf_value() {
1052            let format = KafkaValueFormat::ConfluentProtobuf {
1053                schema_registry: SchemaRegistryConfig::new("http://localhost:8081"),
1054            };
1055            assert!(build_sr_client(&format, None).unwrap().is_some());
1056        }
1057
1058        #[test]
1059        fn build_sr_client_some_for_confluent_json_schema_value() {
1060            let format = KafkaValueFormat::ConfluentJsonSchema {
1061                schema_registry: SchemaRegistryConfig::new("http://localhost:8081"),
1062                validate: true,
1063            };
1064            assert!(build_sr_client(&format, None).unwrap().is_some());
1065        }
1066
1067        #[test]
1068        fn build_sr_client_falls_back_to_key_format() {
1069            let key = KafkaValueFormat::ConfluentAvro {
1070                schema_registry: SchemaRegistryConfig::new("http://localhost:8081"),
1071            };
1072            assert!(
1073                build_sr_client(&KafkaValueFormat::Json, Some(&key))
1074                    .unwrap()
1075                    .is_some()
1076            );
1077        }
1078
1079        #[test]
1080        fn build_sr_client_propagates_invalid_url() {
1081            let format = KafkaValueFormat::ConfluentAvro {
1082                schema_registry: SchemaRegistryConfig::new("not-a-url"),
1083            };
1084            assert!(build_sr_client(&format, None).is_err());
1085        }
1086    }
1087}