use crate::config::KafkaSourceConfig;
use crate::context::{Intent, SourceContext};
use crate::lane::KafkaLane;
use crate::metrics::KafkaStatsMetrics;
use rdkafka::consumer::{BaseConsumer, Consumer};
use rdkafka::message::Message;
use rdkafka::statistics::Statistics;
use rdkafka::{Offset, TopicPartitionList};
use spate_core::checkpoint::AckIssuer;
use spate_core::error::{ErrorClass, SourceError};
use spate_core::metrics::SourceMetrics;
use spate_core::record::PartitionId;
use spate_core::source::{DrainBarrier, LaneId, Source, SourceCtx, SourceEvent};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
pub struct KafkaSource {
config: KafkaSourceConfig,
consumer: Option<Arc<BaseConsumer<SourceContext>>>,
issuer: Option<AckIssuer>,
metrics: Option<Arc<SourceMetrics>>,
stats_metrics: Option<KafkaStatsMetrics>,
assignment: HashMap<LaneId, i32>,
revoking: HashMap<LaneId, i32>,
next_lane: u32,
opened_at: Option<Instant>,
saw_first_assignment: bool,
pending_unassign: bool,
main_queue_rewinds: u64,
}
impl std::fmt::Debug for KafkaSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KafkaSource")
.field("topic", &self.config.topic)
.field("group_id", &self.config.group_id)
.field("lanes", &self.assignment.len())
.finish_non_exhaustive()
}
}
impl KafkaSource {
#[must_use]
pub fn new(config: KafkaSourceConfig) -> Self {
KafkaSource {
config,
consumer: None,
issuer: None,
metrics: None,
stats_metrics: None,
assignment: HashMap::new(),
revoking: HashMap::new(),
next_lane: 0,
opened_at: None,
saw_first_assignment: false,
pending_unassign: false,
main_queue_rewinds: 0,
}
}
pub fn from_component_config(
section: &spate_core::config::ComponentConfig,
) -> Result<Self, spate_core::config::ConfigError> {
Ok(Self::new(KafkaSourceConfig::from_component_config(
section,
)?))
}
fn consumer(&self) -> Result<&Arc<BaseConsumer<SourceContext>>, SourceError> {
self.consumer.as_ref().ok_or_else(|| SourceError::Client {
class: ErrorClass::Fatal,
reason: "source used before open()".into(),
})
}
fn tpl_for(&self, partitions: impl IntoIterator<Item = i32>) -> TopicPartitionList {
let mut tpl = TopicPartitionList::new();
for p in partitions {
tpl.add_partition(&self.config.topic, p);
}
tpl
}
fn lanes_tpl(&self, lanes: &[LaneId]) -> TopicPartitionList {
self.tpl_for(lanes.iter().filter_map(|l| self.assignment.get(l).copied()))
}
fn retained_partition_ids(&self) -> Vec<PartitionId> {
self.assignment
.values()
.filter_map(|p| u32::try_from(*p).ok().map(PartitionId))
.collect()
}
fn prune_lag_series(&self) {
if let Some(m) = &self.metrics {
m.retain_partitions(&self.retained_partition_ids());
}
}
fn committable_partitions(&self) -> Vec<i32> {
self.assignment
.values()
.chain(self.revoking.values())
.copied()
.collect()
}
fn accept_assignment(
&mut self,
tpl: &TopicPartitionList,
) -> Result<Vec<KafkaLane>, SourceError> {
let consumer = Arc::clone(self.consumer()?);
let issuer = self.issuer.as_ref().ok_or_else(|| SourceError::Client {
class: ErrorClass::Fatal,
reason: "assignment before open()".into(),
})?;
consumer.assign(tpl).map_err(fatal("assign"))?;
consumer.pause(tpl).map_err(fatal("pause new assignment"))?;
let mut lanes = Vec::new();
for elem in tpl.elements() {
let partition = elem.partition();
let queue = consumer
.split_partition_queue(&self.config.topic, partition)
.ok_or_else(|| SourceError::Client {
class: ErrorClass::Fatal,
reason: format!("no queue for assigned partition {partition}"),
})?;
let lane_id = LaneId(self.next_lane);
self.next_lane += 1;
self.assignment.insert(lane_id, partition);
lanes.push(KafkaLane::new(
lane_id,
PartitionId(u32::try_from(partition).unwrap_or(0)),
queue,
issuer.clone(),
));
}
consumer
.resume(tpl)
.map_err(fatal("resume new assignment"))?;
self.saw_first_assignment = true;
tracing::info!(
partitions = lanes.len(),
topic = %self.config.topic,
"accepted assignment"
);
Ok(lanes)
}
fn publish_stats(&mut self) {
let Some(consumer) = self.consumer.as_ref() else {
return;
};
let Some(stats) = consumer.context().stats.lock().expect("stats lock").take() else {
return;
};
if let Some(metrics) = self.metrics.as_ref() {
publish_lag(
&stats,
&self.config.topic,
&self.retained_partition_ids(),
metrics,
);
}
if let Some(stats_metrics) = self.stats_metrics.as_mut() {
stats_metrics.update(&stats, &self.config.topic);
}
}
}
fn publish_lag(stats: &Statistics, topic: &str, owned: &[PartitionId], metrics: &SourceMetrics) {
let Some(topic) = stats.topics.get(topic) else {
return;
};
for (pid, p) in &topic.partitions {
if p.consumer_lag >= 0
&& let Ok(part) = u32::try_from(*pid)
&& owned.contains(&PartitionId(part))
{
metrics.set_partition_lag(
PartitionId(part),
u64::try_from(p.consumer_lag).unwrap_or(0),
);
}
}
}
fn fatal(what: &'static str) -> impl Fn(rdkafka::error::KafkaError) -> SourceError {
move |e| SourceError::Client {
class: ErrorClass::Fatal,
reason: format!("{what}: {e}"),
}
}
impl Source for KafkaSource {
type Lane = KafkaLane;
fn component_type(&self) -> &str {
"kafka"
}
fn open(&mut self, ctx: SourceCtx) -> Result<(), SourceError> {
if self.consumer.is_some() {
return Err(SourceError::Client {
class: ErrorClass::Fatal,
reason: "open() called twice".into(),
});
}
self.config.validate().map_err(|e| SourceError::Client {
class: ErrorClass::Fatal,
reason: e.to_string(),
})?;
self.metrics = ctx.stage_metrics.clone();
self.stats_metrics = if self.config.statistics_interval.is_zero() {
tracing::warn!(
topic = %self.config.topic,
"statistics disabled (statistics_interval: 0s): consumer lag \
and the spate_kafka_source_* families will not be published"
);
None
} else {
ctx.meter
.as_ref()
.map(|m| KafkaStatsMetrics::new(m.clone(), ctx.per_partition_detail))
};
let consumer: BaseConsumer<SourceContext> = self
.config
.client_config()
.create_with_context(SourceContext::default())
.map_err(fatal("create consumer"))?;
consumer
.subscribe(&[&self.config.topic])
.map_err(fatal("subscribe"))?;
self.consumer = Some(Arc::new(consumer));
self.issuer = Some(ctx.issuer);
self.opened_at = Some(Instant::now());
Ok(())
}
fn poll_events(&mut self, timeout: Duration) -> Result<SourceEvent<KafkaLane>, SourceError> {
if !self.saw_first_assignment
&& let Some(at) = self.opened_at
&& at.elapsed() > self.config.startup_timeout
{
return Err(SourceError::Client {
class: ErrorClass::Fatal,
reason: format!(
"no partition assignment within {:?} (topic {:?}, brokers {:?})",
self.config.startup_timeout, self.config.topic, self.config.brokers
),
});
}
if self.pending_unassign {
self.pending_unassign = false;
let consumer = Arc::clone(self.consumer()?);
if let Err(e) = consumer.unassign() {
tracing::warn!(error = %e, "unassign after drained revocation");
}
self.revoking.clear();
}
let consumer = Arc::clone(self.consumer()?);
if let Some(result) = consumer.poll(timeout) {
match result {
Ok(msg) => {
self.main_queue_rewinds += 1;
tracing::warn!(
partition = msg.partition(),
offset = msg.offset(),
total = self.main_queue_rewinds,
"message on the main queue; rewinding partition"
);
let tpl = self.tpl_for([msg.partition()]);
let _ = consumer.pause(&tpl);
if let Err(e) = consumer.seek(
&self.config.topic,
msg.partition(),
Offset::Offset(msg.offset()),
Duration::from_secs(5),
) {
tracing::error!(error = %e, "seek for main-queue rewind failed");
}
let _ = consumer.resume(&tpl);
}
Err(e) => {
return Err(SourceError::Client {
class: crate::error::classify_poll_error(&e, self.saw_first_assignment),
reason: format!("consumer poll: {e}"),
});
}
}
}
self.publish_stats();
let intent = {
let ctx = self.consumer()?.context().clone();
let mut intents = ctx.intents.lock().expect("intent lock");
intents.pop_front()
};
if let Some(intent) = intent {
match intent {
Intent::Assign(tpl) => {
if tpl.count() == 0 {
let consumer = Arc::clone(self.consumer()?);
consumer.assign(&tpl).map_err(fatal("assign empty"))?;
self.saw_first_assignment = true;
self.prune_lag_series();
return Ok(SourceEvent::Idle);
}
let lanes = self.accept_assignment(&tpl)?;
self.prune_lag_series();
return Ok(SourceEvent::LanesAssigned(lanes));
}
Intent::Revoke(tpl) => {
let revoked: Vec<i32> = tpl.elements().iter().map(|e| e.partition()).collect();
let lanes: Vec<LaneId> = self
.assignment
.iter()
.filter(|(_, p)| revoked.contains(p))
.map(|(l, _)| *l)
.collect();
for lane in &lanes {
if let Some(p) = self.assignment.remove(lane) {
self.revoking.insert(*lane, p);
}
}
self.pending_unassign = true;
if lanes.is_empty() {
return Ok(SourceEvent::Idle);
}
let barrier = DrainBarrier::new(lanes.len());
return Ok(SourceEvent::LanesRevoked { lanes, barrier });
}
Intent::Error(reason) => {
return Err(SourceError::Client {
class: ErrorClass::Retryable,
reason: format!("rebalance error: {reason}"),
});
}
}
}
Ok(SourceEvent::Idle)
}
fn commit(&mut self, watermarks: &[(PartitionId, i64)]) -> Result<(), SourceError> {
if watermarks.is_empty() {
return Ok(());
}
let consumer = Arc::clone(self.consumer()?);
let owned = self.committable_partitions();
let mut tpl = TopicPartitionList::new();
for (p, offset) in watermarks {
let partition = i32::try_from(p.0).unwrap_or(-1);
if owned.contains(&partition) {
tpl.add_partition_offset(&self.config.topic, partition, Offset::Offset(*offset))
.map_err(fatal("build offset list"))?;
} else {
tracing::debug!(
partition = p.0,
offset,
"skipping store for partition no longer owned"
);
}
}
if tpl.count() == 0 {
return Ok(());
}
consumer
.store_offsets(&tpl)
.map_err(|e| SourceError::Client {
class: ErrorClass::Retryable,
reason: format!("store offsets: {e}"),
})
}
fn flush_commits(&mut self) -> Result<(), SourceError> {
let consumer = Arc::clone(self.consumer()?);
match consumer.commit_consumer_state(rdkafka::consumer::CommitMode::Sync) {
Ok(()) => Ok(()),
Err(rdkafka::error::KafkaError::ConsumerCommit(
rdkafka::error::RDKafkaErrorCode::NoOffset,
)) => Ok(()),
Err(e) => Err(SourceError::Client {
class: ErrorClass::Retryable,
reason: format!("sync commit: {e}"),
}),
}
}
fn pause(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
let tpl = self.lanes_tpl(lanes);
if tpl.count() == 0 {
return Ok(());
}
self.consumer()?
.pause(&tpl)
.map_err(|e| SourceError::Client {
class: ErrorClass::Retryable,
reason: format!("pause: {e}"),
})
}
fn resume(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
let tpl = self.lanes_tpl(lanes);
if tpl.count() == 0 {
return Ok(());
}
self.consumer()?
.resume(&tpl)
.map_err(|e| SourceError::Client {
class: ErrorClass::Retryable,
reason: format!("resume: {e}"),
})
}
}
impl Drop for KafkaSource {
fn drop(&mut self) {
if let Some(consumer) = &self.consumer {
consumer
.context()
.closing
.store(true, std::sync::atomic::Ordering::Release);
let deferred_revoke = self.pending_unassign
|| consumer
.context()
.intents
.lock()
.map(|q| q.iter().any(|i| matches!(i, Intent::Revoke(_))))
.unwrap_or(false);
if deferred_revoke && let Err(e) = consumer.unassign() {
tracing::warn!(error = %e, "unassign during source teardown failed");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> KafkaSourceConfig {
KafkaSourceConfig {
brokers: "localhost:9092".into(),
topic: "orders".into(),
group_id: "test".into(),
commit_interval: Duration::from_secs(5),
startup_timeout: Duration::from_secs(30),
statistics_interval: Duration::ZERO,
rdkafka: std::collections::BTreeMap::new(),
}
}
#[test]
fn open_enforces_tls_guard_on_programmatic_source() {
use spate_core::checkpoint::Checkpointer;
let mut config = test_config();
config
.rdkafka
.insert("security.protocol".into(), "ssl".into());
let mut source = KafkaSource::new(config);
let cp = Checkpointer::new();
let result = source.open(SourceCtx::new(cp.handle()));
if cfg!(feature = "tls") {
result.expect("tls build: open succeeds");
} else {
let err = result.expect_err("non-tls build: open rejects the security config");
assert!(err.to_string().contains("kafka-tls"), "actionable: {err}");
}
}
fn revoke_lanes(source: &mut KafkaSource, revoked: &[i32]) {
let lanes: Vec<LaneId> = source
.assignment
.iter()
.filter(|(_, p)| revoked.contains(p))
.map(|(l, _)| *l)
.collect();
for lane in &lanes {
if let Some(p) = source.assignment.remove(lane) {
source.revoking.insert(*lane, p);
}
}
}
#[test]
fn committable_partitions_include_revoking_until_released() {
let mut source = KafkaSource::new(test_config());
for (lane, part) in [(0u32, 0i32), (1, 1), (2, 2), (3, 3)] {
source.assignment.insert(LaneId(lane), part);
}
revoke_lanes(&mut source, &[2, 3]);
let mut owned = source.committable_partitions();
owned.sort_unstable();
assert_eq!(
owned,
vec![0, 1, 2, 3],
"revoked partitions stay committable until unassign releases them"
);
source.revoking.clear();
let mut owned = source.committable_partitions();
owned.sort_unstable();
assert_eq!(owned, vec![0, 1]);
}
#[test]
fn retained_partition_ids_drop_revoked_partitions() {
let mut source = KafkaSource::new(test_config());
for (lane, part) in [(0u32, 0i32), (1, 1), (2, 2)] {
source.assignment.insert(LaneId(lane), part);
}
revoke_lanes(&mut source, &[2]);
let mut kept: Vec<u32> = source
.retained_partition_ids()
.iter()
.map(|p| p.0)
.collect();
kept.sort_unstable();
assert_eq!(kept, vec![0, 1], "revoked partition 2 is not retained");
}
mod lag {
use super::*;
use rdkafka::statistics::{Partition, Topic};
use spate_core::metrics::ComponentLabels;
use std::collections::HashMap;
fn render(f: impl FnOnce(&SourceMetrics)) -> (String, String) {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let component = format!(
"source-{}",
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
);
let std =
format!(r#"pipeline="orders",component="{component}",component_type="kafka""#);
let recorder = metrics_exporter_prometheus::PrometheusBuilder::new().build_recorder();
let handle = recorder.handle();
metrics::with_local_recorder(&recorder, || {
let m = SourceMetrics::new(&ComponentLabels::new("orders", component, "kafka"));
f(&m);
});
handle.run_upkeep();
(handle.render(), std)
}
fn stats(parts: &[(i32, i64)]) -> Statistics {
Statistics {
topics: HashMap::from([(
"orders".to_owned(),
Topic {
topic: "orders".to_owned(),
partitions: parts
.iter()
.map(|&(pid, consumer_lag)| {
(
pid,
Partition {
partition: pid,
consumer_lag,
..Default::default()
},
)
})
.collect(),
..Default::default()
},
)]),
..Default::default()
}
}
#[test]
fn a_large_backlog_publishes_per_partition_lag() {
let (rendered, std) = render(|m| {
publish_lag(
&stats(&[(0, 150_000_000), (1, 90_000_000)]),
"orders",
&[PartitionId(0), PartitionId(1)],
m,
);
});
assert!(
rendered.contains(&format!(
r#"spate_source_lag_records{{{std},partition="0"}} 150000000"#
)),
"backlogged partition must report its lag:\n{rendered}"
);
assert!(
rendered.contains(&format!(
r#"spate_source_lag_records{{{std},partition="1"}} 90000000"#
)),
"every owned partition gets its own series:\n{rendered}"
);
}
#[test]
fn no_unlabelled_aggregate_series_is_published() {
let (rendered, _std) = render(|m| {
publish_lag(
&stats(&[(0, 17), (1, 4)]),
"orders",
&[PartitionId(0), PartitionId(1)],
m,
);
});
let unlabelled = rendered
.lines()
.filter(|l| l.starts_with("spate_source_lag_records{"))
.any(|l| !l.contains("partition="));
assert!(
!unlabelled,
"every lag series must carry a partition label:\n{rendered}"
);
}
#[test]
fn unknown_lag_registers_no_series() {
let (rendered, _std) = render(|m| {
publish_lag(
&stats(&[(0, -1), (1, -1)]),
"orders",
&[PartitionId(0), PartitionId(1)],
m,
);
});
assert!(
!rendered.contains("spate_source_lag_records"),
"an all-unknown snapshot must publish nothing:\n{rendered}"
);
}
#[test]
fn mixed_snapshot_publishes_only_known_partitions() {
let (rendered, std) = render(|m| {
publish_lag(
&stats(&[(0, 4_200), (1, -1)]),
"orders",
&[PartitionId(0), PartitionId(1)],
m,
);
});
assert!(rendered.contains(&format!(
r#"spate_source_lag_records{{{std},partition="0"}} 4200"#
)));
assert!(
!rendered.contains(r#"partition="1""#),
"unknown partition must be absent:\n{rendered}"
);
}
#[test]
fn a_known_partition_holds_its_value_when_lag_goes_unknown() {
let (rendered, std) = render(|m| {
publish_lag(&stats(&[(0, 5_000)]), "orders", &[PartitionId(0)], m);
publish_lag(&stats(&[(0, -1)]), "orders", &[PartitionId(0)], m);
});
assert!(
rendered.contains(&format!(
r#"spate_source_lag_records{{{std},partition="0"}} 5000"#
)),
"last known value is held:\n{rendered}"
);
}
#[test]
fn a_snapshot_without_our_topic_publishes_nothing() {
let (rendered, _std) = render(|m| {
publish_lag(&stats(&[(0, 900)]), "other-topic", &[PartitionId(0)], m);
});
assert!(
!rendered.contains("spate_source_lag_records"),
"wrong topic must publish nothing:\n{rendered}"
);
}
#[test]
fn revoked_partitions_zero_out_and_stop_updating() {
let (rendered, std) = render(|m| {
publish_lag(
&stats(&[(0, 11), (1, 22)]),
"orders",
&[PartitionId(0), PartitionId(1)],
m,
);
m.retain_partitions(&[PartitionId(0)]);
publish_lag(&stats(&[(0, 33), (1, 44)]), "orders", &[PartitionId(0)], m);
});
assert!(rendered.contains(&format!(
r#"spate_source_lag_records{{{std},partition="0"}} 33"#
)));
assert!(
rendered.contains(&format!(
r#"spate_source_lag_records{{{std},partition="1"}} 0"#
)),
"revoked partition must be zeroed, not left at its last lag:\n{rendered}"
);
assert!(
!rendered.contains(r#"partition="1"} 44"#),
"revoked partition must not resume updating:\n{rendered}"
);
}
}
}