1use 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 assigned_floor: std::sync::Mutex<HashMap<(String, i32), i64>>,
39 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 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 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 Some(rdkafka::Offset::Offset(n)) => out.push(PartitionOffset {
151 topic: key.0,
152 partition: key.1,
153 offset: *n,
154 }),
155 _ => 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 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 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 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 fn member_mode(&self) -> bool {
238 self.context.member_mode.load(Ordering::Acquire)
239 }
240
241 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 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 async fn total_partitions(&self) -> Option<usize> {
320 let consumer = Arc::clone(&self.consumer);
321 let topics = self.config.topics.clone();
322 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 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 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 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 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 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 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 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 to_commit = Some(durable_snapshot);
672 }
673
674 if stop {
675 break;
676 }
677 }
678
679 if !buffer.is_empty() {
683 let bookmark = self.build_bookmark(&pending_offsets).await?;
684 yield StreamPage { records: buffer, bookmark };
685 }
686
687 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 {
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 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 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 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 fn is_shardable(&self) -> bool {
822 true
823 }
824
825 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 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 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 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 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 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 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 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; 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; }
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}