Skip to main content

llm_optimizer_processor/kafka/
source.rs

1//! Kafka source consumer implementation
2//!
3//! This module provides a robust Kafka consumer implementation for ingesting
4//! events from Kafka topics with support for:
5//! - Async message consumption with tokio
6//! - Automatic reconnection on errors
7//! - Configurable commit strategies (auto, manual, periodic)
8//! - Multiple partition support
9//! - Graceful shutdown
10//! - Message deserialization with error handling
11//! - Offset management integration with state backend
12//! - Comprehensive metrics tracking
13
14use chrono::{DateTime, Utc};
15use rdkafka::config::ClientConfig;
16use rdkafka::consumer::{Consumer, ConsumerContext, Rebalance, StreamConsumer};
17use rdkafka::error::{KafkaError, KafkaResult};
18use rdkafka::message::{BorrowedMessage, Headers};
19use rdkafka::{ClientContext, Message, Offset, TopicPartitionList};
20use serde::{de::DeserializeOwned, Deserialize, Serialize};
21use std::collections::HashMap;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25use tokio::sync::{mpsc, RwLock};
26use tokio::time;
27use tracing::{debug, error, info, warn};
28
29use crate::error::{ProcessorError, Result};
30use crate::state::StateBackend;
31
32/// Default poll timeout for Kafka consumer
33const DEFAULT_POLL_TIMEOUT: Duration = Duration::from_millis(100);
34
35/// Default commit interval for periodic commits
36const DEFAULT_COMMIT_INTERVAL: Duration = Duration::from_secs(5);
37
38/// Default max retries for message consumption
39const DEFAULT_MAX_RETRIES: u32 = 3;
40
41/// Default backoff for retries
42const DEFAULT_RETRY_BACKOFF: Duration = Duration::from_secs(1);
43
44/// Commit strategy for offset management
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum CommitStrategy {
48    /// Automatic commits by rdkafka (not recommended for exactly-once)
49    Auto,
50    /// Manual commit after each message
51    Manual,
52    /// Periodic commit at specified interval
53    Periodic,
54    /// Commit on checkpoint (integration with state backend)
55    OnCheckpoint,
56}
57
58/// Configuration for Kafka source consumer
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct KafkaSourceConfig {
61    /// Kafka brokers (comma-separated list)
62    pub brokers: String,
63    /// Consumer group ID
64    pub group_id: String,
65    /// Topics to subscribe to
66    pub topics: Vec<String>,
67    /// Commit strategy
68    pub commit_strategy: CommitStrategy,
69    /// Commit interval for periodic commits
70    pub commit_interval_secs: Option<u64>,
71    /// Enable auto offset reset to earliest
72    pub auto_offset_reset: Option<String>,
73    /// Additional Kafka consumer configuration
74    pub extra_config: HashMap<String, String>,
75    /// Maximum retries for message consumption
76    pub max_retries: Option<u32>,
77    /// Retry backoff duration in milliseconds
78    pub retry_backoff_ms: Option<u64>,
79    /// Poll timeout in milliseconds
80    pub poll_timeout_ms: Option<u64>,
81}
82
83impl Default for KafkaSourceConfig {
84    fn default() -> Self {
85        Self {
86            brokers: "localhost:9092".to_string(),
87            group_id: "llm-optimizer-processor".to_string(),
88            topics: vec![],
89            commit_strategy: CommitStrategy::Periodic,
90            commit_interval_secs: Some(5),
91            auto_offset_reset: Some("earliest".to_string()),
92            extra_config: HashMap::new(),
93            max_retries: Some(DEFAULT_MAX_RETRIES),
94            retry_backoff_ms: Some(DEFAULT_RETRY_BACKOFF.as_millis() as u64),
95            poll_timeout_ms: Some(DEFAULT_POLL_TIMEOUT.as_millis() as u64),
96        }
97    }
98}
99
100/// Metrics tracked by the Kafka source
101#[derive(Debug, Clone)]
102pub struct KafkaSourceMetrics {
103    /// Total messages consumed
104    pub messages_consumed: u64,
105    /// Total messages failed
106    pub messages_failed: u64,
107    /// Messages consumed per partition
108    pub messages_per_partition: HashMap<i32, u64>,
109    /// Current lag per partition (estimated)
110    pub lag_per_partition: HashMap<i32, i64>,
111    /// Total bytes consumed
112    pub bytes_consumed: u64,
113    /// Messages per second (last minute)
114    pub throughput_msgs_per_sec: f64,
115    /// Bytes per second (last minute)
116    pub throughput_bytes_per_sec: f64,
117    /// Average message size
118    pub avg_message_size: f64,
119    /// Last commit timestamp
120    pub last_commit_time: Option<DateTime<Utc>>,
121    /// Total commits
122    pub total_commits: u64,
123    /// Rebalance count
124    pub rebalance_count: u64,
125    /// Current partition assignment
126    pub assigned_partitions: Vec<i32>,
127}
128
129impl Default for KafkaSourceMetrics {
130    fn default() -> Self {
131        Self {
132            messages_consumed: 0,
133            messages_failed: 0,
134            messages_per_partition: HashMap::new(),
135            lag_per_partition: HashMap::new(),
136            bytes_consumed: 0,
137            throughput_msgs_per_sec: 0.0,
138            throughput_bytes_per_sec: 0.0,
139            avg_message_size: 0.0,
140            last_commit_time: None,
141            total_commits: 0,
142            rebalance_count: 0,
143            assigned_partitions: vec![],
144        }
145    }
146}
147
148/// Custom consumer context for handling callbacks
149struct SourceConsumerContext {
150    metrics: Arc<RwLock<KafkaSourceMetrics>>,
151    state_backend: Option<Arc<dyn StateBackend>>,
152}
153
154impl ClientContext for SourceConsumerContext {}
155
156impl ConsumerContext for SourceConsumerContext {
157    fn pre_rebalance(&self, rebalance: &Rebalance) {
158        match rebalance {
159            Rebalance::Revoke(tpl) => {
160                info!("Partition revocation: {:?}", tpl);
161                let metrics = self.metrics.clone();
162                tokio::spawn(async move {
163                    let mut metrics = metrics.write().await;
164                    metrics.rebalance_count += 1;
165                });
166            }
167            Rebalance::Assign(tpl) => {
168                info!("Partition assignment: {:?}", tpl);
169            }
170            Rebalance::Error(err) => {
171                error!("Rebalance error: {}", err);
172            }
173        }
174    }
175
176    fn post_rebalance(&self, rebalance: &Rebalance) {
177        match rebalance {
178            Rebalance::Assign(tpl) => {
179                let partitions: Vec<i32> = tpl
180                    .elements()
181                    .iter()
182                    .map(|elem| elem.partition())
183                    .collect();
184                info!("Successfully assigned partitions: {:?}", partitions);
185
186                let metrics = self.metrics.clone();
187                tokio::spawn(async move {
188                    let mut metrics = metrics.write().await;
189                    metrics.assigned_partitions = partitions;
190                });
191            }
192            Rebalance::Revoke(_) => {
193                info!("Partitions revoked successfully");
194            }
195            Rebalance::Error(err) => {
196                error!("Post-rebalance error: {}", err);
197            }
198        }
199    }
200
201    fn commit_callback(&self, result: KafkaResult<()>, _offsets: &TopicPartitionList) {
202        match result {
203            Ok(_) => {
204                let metrics = self.metrics.clone();
205                tokio::spawn(async move {
206                    let mut metrics = metrics.write().await;
207                    metrics.total_commits += 1;
208                    metrics.last_commit_time = Some(Utc::now());
209                });
210            }
211            Err(err) => {
212                error!("Commit callback error: {}", err);
213            }
214        }
215    }
216}
217
218/// Message envelope with metadata
219#[derive(Debug, Clone)]
220pub struct KafkaMessage<T> {
221    /// Deserialized payload
222    pub payload: T,
223    /// Message key (if present)
224    pub key: Option<Vec<u8>>,
225    /// Topic name
226    pub topic: String,
227    /// Partition
228    pub partition: i32,
229    /// Offset
230    pub offset: i64,
231    /// Timestamp (if available)
232    pub timestamp: Option<DateTime<Utc>>,
233    /// Headers (if present)
234    pub headers: HashMap<String, Vec<u8>>,
235}
236
237/// Kafka source consumer for ingesting events
238pub struct KafkaSource<T> {
239    /// Kafka consumer
240    consumer: Arc<StreamConsumer<SourceConsumerContext>>,
241    /// Configuration
242    config: KafkaSourceConfig,
243    /// Metrics
244    metrics: Arc<RwLock<KafkaSourceMetrics>>,
245    /// State backend for offset management
246    state_backend: Option<Arc<dyn StateBackend>>,
247    /// Shutdown signal
248    shutdown: Arc<AtomicBool>,
249    /// Throughput tracking
250    throughput_tracker: Arc<RwLock<ThroughputTracker>>,
251    /// Phantom data for type parameter
252    _phantom: std::marker::PhantomData<T>,
253}
254
255/// Tracks throughput over time windows
256struct ThroughputTracker {
257    /// Messages in current window
258    messages_in_window: u64,
259    /// Bytes in current window
260    bytes_in_window: u64,
261    /// Window start time
262    window_start: Instant,
263    /// Window duration
264    window_duration: Duration,
265}
266
267impl ThroughputTracker {
268    fn new() -> Self {
269        Self {
270            messages_in_window: 0,
271            bytes_in_window: 0,
272            window_start: Instant::now(),
273            window_duration: Duration::from_secs(60),
274        }
275    }
276
277    fn record(&mut self, bytes: usize) {
278        self.messages_in_window += 1;
279        self.bytes_in_window += bytes as u64;
280    }
281
282    fn calculate(&mut self) -> (f64, f64) {
283        let elapsed = self.window_start.elapsed();
284
285        if elapsed >= self.window_duration {
286            let secs = elapsed.as_secs_f64();
287            let msgs_per_sec = self.messages_in_window as f64 / secs;
288            let bytes_per_sec = self.bytes_in_window as f64 / secs;
289
290            // Reset for next window
291            self.messages_in_window = 0;
292            self.bytes_in_window = 0;
293            self.window_start = Instant::now();
294
295            (msgs_per_sec, bytes_per_sec)
296        } else {
297            (0.0, 0.0)
298        }
299    }
300}
301
302impl<T> KafkaSource<T>
303where
304    T: DeserializeOwned + Send + Sync + 'static,
305{
306    /// Create a new Kafka source consumer
307    ///
308    /// # Arguments
309    ///
310    /// * `config` - Kafka source configuration
311    /// * `state_backend` - Optional state backend for offset management
312    ///
313    /// # Returns
314    ///
315    /// * `Result<Self>` - New Kafka source instance
316    ///
317    /// # Example
318    ///
319    /// ```rust,no_run
320    /// use processor::kafka::{KafkaSource, KafkaSourceConfig};
321    /// use serde::{Deserialize, Serialize};
322    ///
323    /// #[derive(Debug, Deserialize, Serialize)]
324    /// struct MyEvent {
325    ///     id: String,
326    ///     value: f64,
327    /// }
328    ///
329    /// #[tokio::main]
330    /// async fn main() -> anyhow::Result<()> {
331    ///     let config = KafkaSourceConfig {
332    ///         brokers: "localhost:9092".to_string(),
333    ///         group_id: "my-consumer-group".to_string(),
334    ///         topics: vec!["my-topic".to_string()],
335    ///         ..Default::default()
336    ///     };
337    ///
338    ///     let source = KafkaSource::<MyEvent>::new(config, None).await?;
339    ///     Ok(())
340    /// }
341    /// ```
342    pub async fn new(
343        config: KafkaSourceConfig,
344        state_backend: Option<Arc<dyn StateBackend>>,
345    ) -> Result<Self> {
346        let metrics = Arc::new(RwLock::new(KafkaSourceMetrics::default()));
347
348        let context = SourceConsumerContext {
349            metrics: metrics.clone(),
350            state_backend: state_backend.clone(),
351        };
352
353        let consumer = Self::create_consumer(&config, context)?;
354
355        Ok(Self {
356            consumer: Arc::new(consumer),
357            config,
358            metrics,
359            state_backend,
360            shutdown: Arc::new(AtomicBool::new(false)),
361            throughput_tracker: Arc::new(RwLock::new(ThroughputTracker::new())),
362            _phantom: std::marker::PhantomData,
363        })
364    }
365
366    /// Create the underlying Kafka consumer
367    fn create_consumer(
368        config: &KafkaSourceConfig,
369        context: SourceConsumerContext,
370    ) -> Result<StreamConsumer<SourceConsumerContext>> {
371        let mut client_config = ClientConfig::new();
372
373        client_config
374            .set("bootstrap.servers", &config.brokers)
375            .set("group.id", &config.group_id)
376            .set("enable.auto.commit", "false"); // We handle commits manually
377
378        // Set auto offset reset
379        if let Some(ref reset) = config.auto_offset_reset {
380            client_config.set("auto.offset.reset", reset);
381        }
382
383        // Apply extra configuration
384        for (key, value) in &config.extra_config {
385            client_config.set(key, value);
386        }
387
388        // Create consumer
389        let consumer: StreamConsumer<SourceConsumerContext> = client_config
390            .create_with_context(context)
391            .map_err(|e| ProcessorError::Configuration {
392                source: Box::new(e),
393            })?;
394
395        Ok(consumer)
396    }
397
398    /// Subscribe to topics
399    ///
400    /// # Arguments
401    ///
402    /// * `topics` - Topics to subscribe to
403    ///
404    /// # Returns
405    ///
406    /// * `Result<()>` - Success or error
407    pub async fn subscribe(&self, topics: &[String]) -> Result<()> {
408        let topic_refs: Vec<&str> = topics.iter().map(|s| s.as_str()).collect();
409
410        self.consumer
411            .subscribe(&topic_refs)
412            .map_err(|e| ProcessorError::Configuration {
413                source: Box::new(e),
414            })?;
415
416        info!("Subscribed to topics: {:?}", topics);
417        Ok(())
418    }
419
420    /// Poll for a single message
421    ///
422    /// # Returns
423    ///
424    /// * `Result<Option<KafkaMessage<T>>>` - Message or None if timeout/shutdown
425    ///
426    /// # Example
427    ///
428    /// ```rust,no_run
429    /// # use processor::kafka::{KafkaSource, KafkaSourceConfig};
430    /// # use serde::{Deserialize, Serialize};
431    /// # #[derive(Debug, Deserialize, Serialize)]
432    /// # struct MyEvent { id: String }
433    /// # #[tokio::main]
434    /// # async fn main() -> anyhow::Result<()> {
435    /// # let config = KafkaSourceConfig::default();
436    /// # let source = KafkaSource::<MyEvent>::new(config, None).await?;
437    /// while let Some(msg) = source.poll().await? {
438    ///     println!("Received: {:?}", msg.payload);
439    ///     source.commit().await?;
440    /// }
441    /// # Ok(())
442    /// # }
443    /// ```
444    pub async fn poll(&self) -> Result<Option<KafkaMessage<T>>> {
445        if self.shutdown.load(Ordering::Relaxed) {
446            return Ok(None);
447        }
448
449        let _timeout = Duration::from_millis(
450            self.config.poll_timeout_ms.unwrap_or(DEFAULT_POLL_TIMEOUT.as_millis() as u64)
451        );
452
453        match self.consumer.recv().await {
454            Ok(msg) => {
455                let kafka_msg = self.process_message(&msg).await?;
456
457                // Update metrics
458                self.update_metrics(&msg).await;
459
460                Ok(Some(kafka_msg))
461            }
462            Err(KafkaError::PartitionEOF(_)) => {
463                debug!("Reached end of partition");
464                Ok(None)
465            }
466            Err(e) => {
467                error!("Error polling message: {}", e);
468                let mut metrics = self.metrics.write().await;
469                metrics.messages_failed += 1;
470
471                Err(ProcessorError::Execution {
472                    source: Box::new(e),
473                })
474            }
475        }
476    }
477
478    /// Process a borrowed message into a typed message
479    async fn process_message(&self, msg: &BorrowedMessage<'_>) -> Result<KafkaMessage<T>> {
480        // Extract payload
481        let payload_bytes = msg.payload().ok_or_else(|| ProcessorError::Unexpected(
482            "Message has no payload".to_string(),
483        ))?;
484
485        // Deserialize payload
486        let payload: T = serde_json::from_slice(payload_bytes)
487            .map_err(|e| ProcessorError::Serialization(format!("Failed to deserialize message: {}", e)))?;
488
489        // Extract key
490        let key = msg.key().map(|k| k.to_vec());
491
492        // Extract headers
493        let mut headers = HashMap::new();
494        if let Some(msg_headers) = msg.headers() {
495            for idx in 0..msg_headers.count() {
496                let header = msg_headers.get(idx);
497                headers.insert(header.key.to_string(), header.value.map(|v| v.to_vec()).unwrap_or_default());
498            }
499        }
500
501        // Extract timestamp
502        let timestamp = msg.timestamp().to_millis().and_then(|millis| {
503            DateTime::from_timestamp_millis(millis)
504        });
505
506        Ok(KafkaMessage {
507            payload,
508            key,
509            topic: msg.topic().to_string(),
510            partition: msg.partition(),
511            offset: msg.offset(),
512            timestamp,
513            headers,
514        })
515    }
516
517    /// Update metrics after processing a message
518    async fn update_metrics(&self, msg: &BorrowedMessage<'_>) {
519        let partition = msg.partition();
520        let payload_size = msg.payload().map(|p| p.len()).unwrap_or(0);
521
522        let mut metrics = self.metrics.write().await;
523
524        metrics.messages_consumed += 1;
525        metrics.bytes_consumed += payload_size as u64;
526
527        *metrics.messages_per_partition.entry(partition).or_insert(0) += 1;
528
529        // Update average message size
530        if metrics.messages_consumed > 0 {
531            metrics.avg_message_size = metrics.bytes_consumed as f64 / metrics.messages_consumed as f64;
532        }
533
534        drop(metrics);
535
536        // Update throughput
537        let mut tracker = self.throughput_tracker.write().await;
538        tracker.record(payload_size);
539        let (msgs_per_sec, bytes_per_sec) = tracker.calculate();
540
541        if msgs_per_sec > 0.0 {
542            let mut metrics = self.metrics.write().await;
543            metrics.throughput_msgs_per_sec = msgs_per_sec;
544            metrics.throughput_bytes_per_sec = bytes_per_sec;
545        }
546    }
547
548    /// Commit current offsets
549    ///
550    /// # Returns
551    ///
552    /// * `Result<()>` - Success or error
553    pub async fn commit(&self) -> Result<()> {
554        self.consumer
555            .commit_consumer_state(rdkafka::consumer::CommitMode::Async)
556            .map_err(|e| ProcessorError::Execution {
557                source: Box::new(e),
558            })?;
559
560        debug!("Committed offsets");
561        Ok(())
562    }
563
564    /// Seek to a specific offset for a partition
565    ///
566    /// # Arguments
567    ///
568    /// * `topic` - Topic name
569    /// * `partition` - Partition number
570    /// * `offset` - Offset to seek to
571    ///
572    /// # Returns
573    ///
574    /// * `Result<()>` - Success or error
575    pub async fn seek(&self, topic: &str, partition: i32, offset: i64) -> Result<()> {
576        self.consumer
577            .seek(topic, partition, Offset::Offset(offset), Duration::from_secs(10))
578            .map_err(|e| ProcessorError::Execution {
579                source: Box::new(e),
580            })?;
581
582        info!("Seeked to offset {} for {}/{}", offset, topic, partition);
583        Ok(())
584    }
585
586    /// Get current consumption metrics
587    ///
588    /// # Returns
589    ///
590    /// * `KafkaSourceMetrics` - Current metrics snapshot
591    pub async fn metrics(&self) -> KafkaSourceMetrics {
592        self.metrics.read().await.clone()
593    }
594
595    /// Start consuming messages and send them to a channel
596    ///
597    /// This method runs in a loop, consuming messages and sending them to the
598    /// provided channel. It handles automatic commits based on the configured
599    /// commit strategy.
600    ///
601    /// # Arguments
602    ///
603    /// * `tx` - Channel sender for messages
604    ///
605    /// # Returns
606    ///
607    /// * `Result<()>` - Success or error
608    pub async fn start(&self, tx: mpsc::Sender<KafkaMessage<T>>) -> Result<()> {
609        // Subscribe to configured topics
610        self.subscribe(&self.config.topics.clone()).await?;
611
612        let mut commit_interval = time::interval(Duration::from_secs(
613            self.config.commit_interval_secs.unwrap_or(5),
614        ));
615
616        loop {
617            tokio::select! {
618                _ = commit_interval.tick() => {
619                    if self.config.commit_strategy == CommitStrategy::Periodic {
620                        if let Err(e) = self.commit().await {
621                            warn!("Failed to commit offsets: {}", e);
622                        }
623                    }
624                }
625
626                result = self.poll() => {
627                    match result {
628                        Ok(Some(msg)) => {
629                            // Send message to channel
630                            if tx.send(msg).await.is_err() {
631                                warn!("Channel closed, stopping consumer");
632                                break;
633                            }
634
635                            // Commit if strategy is manual
636                            if self.config.commit_strategy == CommitStrategy::Manual {
637                                if let Err(e) = self.commit().await {
638                                    warn!("Failed to commit offset: {}", e);
639                                }
640                            }
641                        }
642                        Ok(None) => {
643                            // Timeout or shutdown
644                            if self.shutdown.load(Ordering::Relaxed) {
645                                info!("Shutdown signal received");
646                                break;
647                            }
648                        }
649                        Err(e) => {
650                            error!("Error consuming message: {}", e);
651
652                            // Implement retry logic
653                            let _max_retries = self.config.max_retries.unwrap_or(DEFAULT_MAX_RETRIES);
654                            let backoff = Duration::from_millis(
655                                self.config.retry_backoff_ms.unwrap_or(DEFAULT_RETRY_BACKOFF.as_millis() as u64)
656                            );
657
658                            tokio::time::sleep(backoff).await;
659                        }
660                    }
661                }
662            }
663        }
664
665        // Final commit before shutdown
666        if let Err(e) = self.commit().await {
667            warn!("Failed to commit offsets on shutdown: {}", e);
668        }
669
670        info!("Kafka source consumer stopped");
671        Ok(())
672    }
673
674    /// Stop the consumer gracefully
675    pub async fn close(&self) -> Result<()> {
676        info!("Stopping Kafka source consumer");
677        self.shutdown.store(true, Ordering::Relaxed);
678
679        // Final commit
680        self.commit().await?;
681
682        info!("Kafka source consumer stopped gracefully");
683        Ok(())
684    }
685
686    /// Get current lag for all assigned partitions
687    ///
688    /// This fetches watermarks from Kafka to calculate lag
689    pub async fn get_lag(&self) -> Result<HashMap<i32, i64>> {
690        let metadata = self.consumer
691            .fetch_metadata(None, Duration::from_secs(10))
692            .map_err(|e| ProcessorError::Execution {
693                source: Box::new(e),
694            })?;
695
696        let mut lag_map = HashMap::new();
697
698        for topic in metadata.topics() {
699            for partition in topic.partitions() {
700                let partition_id = partition.id();
701
702                // Get current position
703                let position = self.consumer
704                    .position()
705                    .map_err(|e| ProcessorError::Execution {
706                        source: Box::new(e),
707                    })?;
708
709                let current_offset = position
710                    .elements_for_topic(topic.name())
711                    .iter()
712                    .find(|e| e.partition() == partition_id)
713                    .and_then(|e| e.offset().to_raw())
714                    .unwrap_or(0);
715
716                // Get high watermark
717                let (_, high_watermark) = self.consumer
718                    .fetch_watermarks(topic.name(), partition_id, Duration::from_secs(10))
719                    .map_err(|e| ProcessorError::Execution {
720                        source: Box::new(e),
721                    })?;
722
723                let lag = high_watermark - current_offset;
724                lag_map.insert(partition_id, lag);
725            }
726        }
727
728        // Update metrics
729        let mut metrics = self.metrics.write().await;
730        metrics.lag_per_partition = lag_map.clone();
731
732        Ok(lag_map)
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use serde::{Deserialize, Serialize};
740
741    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
742    struct TestEvent {
743        id: String,
744        value: f64,
745        timestamp: i64,
746    }
747
748    #[test]
749    fn test_kafka_source_config_default() {
750        let config = KafkaSourceConfig::default();
751        assert_eq!(config.brokers, "localhost:9092");
752        assert_eq!(config.group_id, "llm-optimizer-processor");
753        assert_eq!(config.commit_strategy, CommitStrategy::Periodic);
754    }
755
756    #[test]
757    fn test_commit_strategy_serialization() {
758        let strategy = CommitStrategy::Periodic;
759        let json = serde_json::to_string(&strategy).unwrap();
760        assert_eq!(json, "\"periodic\"");
761
762        let deserialized: CommitStrategy = serde_json::from_str(&json).unwrap();
763        assert_eq!(deserialized, CommitStrategy::Periodic);
764    }
765
766    #[test]
767    fn test_throughput_tracker() {
768        let mut tracker = ThroughputTracker::new();
769
770        // Record some messages
771        for _ in 0..100 {
772            tracker.record(1024);
773        }
774
775        // Not enough time has passed
776        let (msgs, bytes) = tracker.calculate();
777        assert_eq!(msgs, 0.0);
778        assert_eq!(bytes, 0.0);
779    }
780
781    #[tokio::test]
782    async fn test_kafka_message_structure() {
783        let event = TestEvent {
784            id: "test-123".to_string(),
785            value: 42.0,
786            timestamp: 1234567890,
787        };
788
789        let msg = KafkaMessage {
790            payload: event.clone(),
791            key: Some(b"key1".to_vec()),
792            topic: "test-topic".to_string(),
793            partition: 0,
794            offset: 100,
795            timestamp: Some(Utc::now()),
796            headers: HashMap::new(),
797        };
798
799        assert_eq!(msg.payload.id, "test-123");
800        assert_eq!(msg.partition, 0);
801        assert_eq!(msg.offset, 100);
802    }
803
804    #[tokio::test]
805    async fn test_metrics_default() {
806        let metrics = KafkaSourceMetrics::default();
807        assert_eq!(metrics.messages_consumed, 0);
808        assert_eq!(metrics.messages_failed, 0);
809        assert_eq!(metrics.bytes_consumed, 0);
810        assert_eq!(metrics.total_commits, 0);
811        assert_eq!(metrics.rebalance_count, 0);
812    }
813}