mod context;
mod dlq;
mod lag;
mod process;
use dataflow_rs::datalogic_rs;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use rdkafka::ClientConfig;
use rdkafka::consumer::{Consumer, StreamConsumer};
use tokio::sync::watch;
use crate::config::KafkaIngestConfig;
use crate::errors::OrionError;
use crate::kafka::producer::KafkaProducer;
use context::{KafkaConsumerContext, RebalanceState};
use lag::poll_consumer_lag;
use process::process_until_committed;
pub(crate) use process::{INITIAL_RETRY_BACKOFF_MS, next_backoff_ms};
struct ConsumeLoopContext {
consumer: Arc<StreamConsumer<KafkaConsumerContext>>,
topic_map: HashMap<String, String>,
engine: Arc<crate::engine::EngineHandle>,
channel_registry: Arc<crate::channel::ChannelRegistry>,
datalogic: Arc<datalogic_rs::Engine>,
dlq_producer: Option<Arc<KafkaProducer>>,
dlq_topic: Option<String>,
processing_timeout_ms: u64,
lag_poll_interval_secs: u64,
rebalance: Arc<RebalanceState>,
retry_budget_ms: u64,
}
pub struct ConsumerHandle {
shutdown_tx: watch::Sender<bool>,
join_handle: tokio::task::JoinHandle<()>,
consumer: Arc<StreamConsumer<KafkaConsumerContext>>,
topics: HashSet<String>,
rebalance: Arc<RebalanceState>,
}
impl ConsumerHandle {
pub async fn shutdown(self) {
if let Err(e) = self.shutdown_tx.send(true) {
tracing::error!(error = %e, "Failed to send Kafka consumer shutdown signal");
}
if let Err(e) = self.join_handle.await {
tracing::error!(error = %e, "Kafka consumer task panicked during shutdown");
}
}
pub fn pause(&self) -> Result<(), OrionError> {
self.with_assignment("pause", |c, a| c.pause(a))
}
pub fn resume(&self) -> Result<(), OrionError> {
self.with_assignment("resume", |c, a| c.resume(a))
}
pub fn is_finished(&self) -> bool {
self.join_handle.is_finished()
}
fn with_assignment(
&self,
op: &str,
f: impl Fn(
&StreamConsumer<KafkaConsumerContext>,
&rdkafka::TopicPartitionList,
) -> rdkafka::error::KafkaResult<()>,
) -> Result<(), OrionError> {
let assignment = self
.consumer
.assignment()
.map_err(|e| OrionError::internal(format!("Failed to get consumer assignment: {e}")))?;
if assignment.count() == 0 {
return Ok(());
}
f(&self.consumer, &assignment)
.map_err(|e| OrionError::internal(format!("Failed to {op} consumer partitions: {e}")))
}
pub fn topics(&self) -> &HashSet<String> {
&self.topics
}
pub fn rebalance_rounds(&self) -> u64 {
self.rebalance.assign_rounds()
}
}
pub fn start_consumer(
config: &KafkaIngestConfig,
engine: Arc<crate::engine::EngineHandle>,
channel_registry: Arc<crate::channel::ChannelRegistry>,
datalogic: Arc<datalogic_rs::Engine>,
dlq_producer: Option<Arc<KafkaProducer>>,
dlq_topic: Option<String>,
instance_id: Option<&str>,
) -> Result<ConsumerHandle, OrionError> {
let mut client_config = ClientConfig::new();
client_config
.set("bootstrap.servers", config.brokers.join(","))
.set("group.id", &config.group_id)
.set("enable.auto.commit", "false")
.set("auto.offset.reset", "earliest");
client_config.set("session.timeout.ms", config.session_timeout_ms.to_string());
if let Some(id) = instance_id {
client_config.set("group.instance.id", id);
}
super::apply_client_auth(&mut client_config, &config.auth, &config.extra_config);
let rebalance = Arc::new(RebalanceState::new());
let consumer: StreamConsumer<KafkaConsumerContext> = client_config
.create_with_context(KafkaConsumerContext::new(rebalance.clone()))
.map_err(|e| OrionError::Internal {
context: "Failed to create Kafka consumer".to_string(),
source: Some(Box::new(e)),
})?;
match consumer.fetch_metadata(None, std::time::Duration::from_secs(5)) {
Ok(metadata) => {
tracing::info!(
brokers = metadata.brokers().len(),
topics = metadata.topics().len(),
"Kafka broker connectivity verified"
);
}
Err(e) => {
tracing::warn!(
error = %e,
"Kafka broker connectivity check failed — consumer will retry on its own"
);
}
}
let topic_map: HashMap<String, String> = config
.topics
.iter()
.map(|t| (t.topic.clone(), t.channel.clone()))
.collect();
let topics: Vec<&str> = config.topics.iter().map(|t| t.topic.as_str()).collect();
consumer
.subscribe(&topics)
.map_err(|e| OrionError::Internal {
context: "Failed to subscribe to Kafka topics".to_string(),
source: Some(Box::new(e)),
})?;
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let processing_timeout_ms = config.processing_timeout_ms;
let lag_poll_interval_secs = config.lag_poll_interval_secs;
let consumer = Arc::new(consumer);
let topic_set: HashSet<String> = config.topics.iter().map(|t| t.topic.clone()).collect();
let ctx = ConsumeLoopContext {
consumer: consumer.clone(),
topic_map,
engine,
channel_registry,
datalogic,
dlq_producer,
dlq_topic,
processing_timeout_ms,
lag_poll_interval_secs,
rebalance: rebalance.clone(),
retry_budget_ms: process::in_place_retry_budget_ms(&config.extra_config),
};
let handle = tokio::spawn(consume_loop(ctx, shutdown_rx));
Ok(ConsumerHandle {
shutdown_tx,
join_handle: handle,
consumer,
topics: topic_set,
rebalance,
})
}
async fn sleep_or_shutdown(rx: &mut watch::Receiver<bool>, ms: u64) -> bool {
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_millis(ms)) => true,
_ = rx.changed() => !*rx.borrow(),
}
}
async fn consume_loop(ctx: ConsumeLoopContext, mut shutdown_rx: watch::Receiver<bool>) {
let lag_handle = if ctx.lag_poll_interval_secs > 0 {
let lag_consumer = ctx.consumer.clone();
let lag_shutdown = shutdown_rx.clone();
Some(tokio::spawn(poll_consumer_lag(
lag_consumer,
lag_shutdown,
ctx.lag_poll_interval_secs,
)))
} else {
None
};
tracing::info!(
topics = ?ctx.topic_map.keys().collect::<Vec<_>>(),
lag_poll_secs = ctx.lag_poll_interval_secs,
"Kafka consumer started (strictly sequential processing)"
);
let mut recv_backoff_ms: Option<u64> = None;
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
tracing::info!("Kafka consumer shutting down");
break;
}
}
msg_result = ctx.consumer.recv() => {
match msg_result {
Ok(msg) => {
recv_backoff_ms = None;
if !process_until_committed(&ctx, &msg, &mut shutdown_rx).await {
tracing::info!("Kafka consumer shutting down");
break;
}
}
Err(e) => {
let wait = recv_backoff_ms
.map(process::next_backoff_ms)
.unwrap_or(process::INITIAL_RETRY_BACKOFF_MS);
recv_backoff_ms = Some(wait);
crate::metrics::record_error("kafka_recv");
tracing::error!(
error = %e,
backoff_ms = wait,
"Kafka consumer error; backing off before the next poll"
);
if !sleep_or_shutdown(&mut shutdown_rx, wait).await {
tracing::info!("Kafka consumer shutting down");
break;
}
}
}
}
}
}
if let Some(handle) = lag_handle {
handle.abort();
}
tracing::info!("Kafka consumer stopped");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_topic_map_construction() {
let config = crate::config::KafkaIngestConfig {
enabled: true,
brokers: vec!["localhost:9092".into()],
group_id: "test".into(),
topics: vec![
crate::config::TopicMapping {
topic: "orders".into(),
channel: "order-channel".into(),
},
crate::config::TopicMapping {
topic: "events".into(),
channel: "event-channel".into(),
},
],
..Default::default()
};
let topic_map: HashMap<String, String> = config
.topics
.iter()
.map(|t| (t.topic.clone(), t.channel.clone()))
.collect();
assert_eq!(topic_map.len(), 2);
assert_eq!(topic_map.get("orders").expect("test"), "order-channel");
assert_eq!(topic_map.get("events").expect("test"), "event-channel");
assert!(!topic_map.contains_key("unknown"));
}
}