1use 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
32const DEFAULT_POLL_TIMEOUT: Duration = Duration::from_millis(100);
34
35const DEFAULT_COMMIT_INTERVAL: Duration = Duration::from_secs(5);
37
38const DEFAULT_MAX_RETRIES: u32 = 3;
40
41const DEFAULT_RETRY_BACKOFF: Duration = Duration::from_secs(1);
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum CommitStrategy {
48 Auto,
50 Manual,
52 Periodic,
54 OnCheckpoint,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct KafkaSourceConfig {
61 pub brokers: String,
63 pub group_id: String,
65 pub topics: Vec<String>,
67 pub commit_strategy: CommitStrategy,
69 pub commit_interval_secs: Option<u64>,
71 pub auto_offset_reset: Option<String>,
73 pub extra_config: HashMap<String, String>,
75 pub max_retries: Option<u32>,
77 pub retry_backoff_ms: Option<u64>,
79 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#[derive(Debug, Clone)]
102pub struct KafkaSourceMetrics {
103 pub messages_consumed: u64,
105 pub messages_failed: u64,
107 pub messages_per_partition: HashMap<i32, u64>,
109 pub lag_per_partition: HashMap<i32, i64>,
111 pub bytes_consumed: u64,
113 pub throughput_msgs_per_sec: f64,
115 pub throughput_bytes_per_sec: f64,
117 pub avg_message_size: f64,
119 pub last_commit_time: Option<DateTime<Utc>>,
121 pub total_commits: u64,
123 pub rebalance_count: u64,
125 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
148struct 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#[derive(Debug, Clone)]
220pub struct KafkaMessage<T> {
221 pub payload: T,
223 pub key: Option<Vec<u8>>,
225 pub topic: String,
227 pub partition: i32,
229 pub offset: i64,
231 pub timestamp: Option<DateTime<Utc>>,
233 pub headers: HashMap<String, Vec<u8>>,
235}
236
237pub struct KafkaSource<T> {
239 consumer: Arc<StreamConsumer<SourceConsumerContext>>,
241 config: KafkaSourceConfig,
243 metrics: Arc<RwLock<KafkaSourceMetrics>>,
245 state_backend: Option<Arc<dyn StateBackend>>,
247 shutdown: Arc<AtomicBool>,
249 throughput_tracker: Arc<RwLock<ThroughputTracker>>,
251 _phantom: std::marker::PhantomData<T>,
253}
254
255struct ThroughputTracker {
257 messages_in_window: u64,
259 bytes_in_window: u64,
261 window_start: Instant,
263 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 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 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 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"); if let Some(ref reset) = config.auto_offset_reset {
380 client_config.set("auto.offset.reset", reset);
381 }
382
383 for (key, value) in &config.extra_config {
385 client_config.set(key, value);
386 }
387
388 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 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 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 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 async fn process_message(&self, msg: &BorrowedMessage<'_>) -> Result<KafkaMessage<T>> {
480 let payload_bytes = msg.payload().ok_or_else(|| ProcessorError::Unexpected(
482 "Message has no payload".to_string(),
483 ))?;
484
485 let payload: T = serde_json::from_slice(payload_bytes)
487 .map_err(|e| ProcessorError::Serialization(format!("Failed to deserialize message: {}", e)))?;
488
489 let key = msg.key().map(|k| k.to_vec());
491
492 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 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 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 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 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 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 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 pub async fn metrics(&self) -> KafkaSourceMetrics {
592 self.metrics.read().await.clone()
593 }
594
595 pub async fn start(&self, tx: mpsc::Sender<KafkaMessage<T>>) -> Result<()> {
609 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 if tx.send(msg).await.is_err() {
631 warn!("Channel closed, stopping consumer");
632 break;
633 }
634
635 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 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 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 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 pub async fn close(&self) -> Result<()> {
676 info!("Stopping Kafka source consumer");
677 self.shutdown.store(true, Ordering::Relaxed);
678
679 self.commit().await?;
681
682 info!("Kafka source consumer stopped gracefully");
683 Ok(())
684 }
685
686 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 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 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 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 for _ in 0..100 {
772 tracker.record(1024);
773 }
774
775 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}