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};
struct AssignmentWait {
since: Instant,
cause: Option<String>,
}
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,
saw_first_assignment: bool,
pending_unassign: bool,
pending_error: Option<rdkafka::error::RDKafkaErrorCode>,
assignment_wait: Option<AssignmentWait>,
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,
saw_first_assignment: false,
pending_unassign: false,
pending_error: None,
assignment_wait: None,
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 rebalance_error(&self, code: rdkafka::error::RDKafkaErrorCode) -> SourceError {
SourceError::Client {
class: crate::error::classify_consumer_error(code, self.saw_first_assignment),
reason: format!("rebalance error: {code}"),
}
}
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;
self.assignment_wait = None;
tracing::info!(
partitions = lanes.len(),
topic = %self.config.topic,
"accepted assignment"
);
Ok(lanes)
}
fn note_assignment_loss(&mut self, cause: String) {
if !self.saw_first_assignment {
return;
}
match &mut self.assignment_wait {
Some(wait) => wait.cause = Some(cause),
None => {
self.assignment_wait = Some(AssignmentWait {
since: Instant::now(),
cause: Some(cause),
})
}
}
}
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 assignment_deadline_error(
config: &KafkaSourceConfig,
waited: Duration,
cause: Option<&str>,
) -> Option<SourceError> {
let deadline = match cause {
None => config.startup_timeout,
Some(_) => config.assignment_timeout,
};
if deadline.is_zero() || waited <= deadline {
return None;
}
let reason = match cause {
None => format!(
"no partition assignment within {waited:?} \
(topic {:?}, brokers {:?})",
config.topic, config.brokers
),
Some(cause) => format!(
"no partition assignment for {waited:?} after {cause} \
(group {:?}, topic {:?})",
config.group_id, config.topic
),
};
Some(SourceError::Client {
class: ErrorClass::Fatal,
reason,
})
}
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.assignment_wait = Some(AssignmentWait {
since: Instant::now(),
cause: None,
});
Ok(())
}
fn poll_events(&mut self, timeout: Duration) -> Result<SourceEvent<KafkaLane>, SourceError> {
if let Some(wait) = &self.assignment_wait
&& let Some(e) =
assignment_deadline_error(&self.config, wait.since.elapsed(), wait.cause.as_deref())
{
return Err(e);
}
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();
if self.assignment.is_empty() {
self.note_assignment_loss("a revocation".to_owned());
}
}
if let Some(code) = self.pending_error.take() {
return Err(self.rebalance_error(code));
}
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, queued) = {
let ctx = self.consumer()?.context().clone();
let mut intents = ctx.intents.lock().expect("intent lock");
let intent = intents.pop_front();
let queued: Vec<&'static str> = intents.iter().map(Intent::kind).collect();
(intent, queued)
};
if let Some(intent) = &intent
&& !queued.is_empty()
{
tracing::warn!(
processing = intent.kind(),
queued = ?queued,
"rebalance intents piled up; completing one per poll"
);
}
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.assignment_wait = None;
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(code) => {
let lanes: Vec<LaneId> = self.assignment.keys().copied().collect();
self.assignment.clear();
self.note_assignment_loss(format!("rebalance error: {code}"));
self.revoking.clear();
self.prune_lag_series();
if lanes.is_empty() {
return Err(self.rebalance_error(code));
}
self.pending_error = Some(code);
let barrier = DrainBarrier::new(lanes.len());
return Ok(SourceEvent::LanesRevoked { lanes, barrier });
}
}
}
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 {
tracing::warn!(
refused = watermarks.len(),
"refusing to store watermarks for partitions no longer owned; \
their work will replay"
);
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::*;
use rdkafka::error::RDKafkaErrorCode;
use spate_core::checkpoint::Checkpointer;
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),
assignment_timeout: Duration::from_mins(5),
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]);
}
fn opened_source(lanes: &[(u32, i32)]) -> KafkaSource {
opened_source_with(test_config(), lanes)
}
fn opened_source_with(mut cfg: KafkaSourceConfig, lanes: &[(u32, i32)]) -> KafkaSource {
cfg.brokers = "127.0.0.1:1".into();
let mut source = KafkaSource::new(cfg);
let cp = Checkpointer::new();
source.open(SourceCtx::new(cp.handle())).expect("open");
source.saw_first_assignment = true;
source.assignment_wait = None;
for &(lane, part) in lanes {
source.assignment.insert(LaneId(lane), part);
}
source
}
fn push_intent(source: &KafkaSource, intent: Intent) {
source
.consumer
.as_ref()
.expect("opened")
.context()
.intents
.lock()
.expect("intent lock")
.push_back(intent);
}
fn push_error(source: &KafkaSource, code: RDKafkaErrorCode) {
push_intent(source, Intent::Error(code));
}
fn next_outcome(source: &mut KafkaSource) -> Result<SourceEvent<KafkaLane>, SourceError> {
let deadline = Instant::now() + Duration::from_secs(20);
loop {
assert!(Instant::now() < deadline, "no outcome within deadline");
match source.poll_events(Duration::from_millis(50)) {
Ok(SourceEvent::Idle) => continue,
Err(e) if e.to_string().contains("consumer poll") => continue,
other => return other,
}
}
}
mod rebalance_error {
use super::*;
#[test]
fn live_lanes_are_revoked_then_the_error_reports() {
let mut source = opened_source(&[(0, 0), (1, 1)]);
push_error(&source, RDKafkaErrorCode::RebalanceInProgress);
match next_outcome(&mut source) {
Ok(SourceEvent::LanesRevoked { mut lanes, barrier }) => {
lanes.sort();
assert_eq!(lanes, vec![LaneId(0), LaneId(1)]);
assert_eq!(barrier.remaining(), 2);
barrier.arrive();
barrier.arrive();
}
other => panic!("expected LanesRevoked, got {other:?}"),
}
assert!(source.assignment.is_empty(), "ownership cleared");
assert!(source.committable_partitions().is_empty());
match next_outcome(&mut source) {
Err(SourceError::Client { class, reason }) => {
assert_eq!(class, ErrorClass::Retryable, "transient code: {reason}");
assert!(reason.contains("rebalance error"), "{reason}");
}
other => panic!("expected the classified error, got {other:?}"),
}
}
#[test]
fn an_authorization_error_is_fatal() {
let mut source = opened_source(&[]);
push_error(&source, RDKafkaErrorCode::GroupAuthorizationFailed);
match next_outcome(&mut source) {
Err(SourceError::Client { class, reason }) => {
assert_eq!(class, ErrorClass::Fatal, "{reason}");
assert!(reason.contains("rebalance error"), "{reason}");
}
other => panic!("expected a fatal error, got {other:?}"),
}
}
}
mod assignment_deadline {
use super::*;
fn poll_until_armed(source: &mut KafkaSource) -> String {
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some(wait) = &source.assignment_wait {
return wait.cause.clone().expect("a loss names its cause");
}
assert!(Instant::now() < deadline, "the deadline was never armed");
let _ = source.poll_events(Duration::from_millis(50));
}
}
fn poll_until_cleared(source: &mut KafkaSource) {
let deadline = Instant::now() + Duration::from_secs(20);
while source.assignment_wait.is_some() {
assert!(
Instant::now() < deadline,
"the assignment never cleared the deadline"
);
let _ = source.poll_events(Duration::from_millis(50));
}
}
#[test]
fn an_expired_deadline_is_fatal_and_names_the_group() {
let mut cfg = test_config();
cfg.assignment_timeout = Duration::from_secs(300);
let error = assignment_deadline_error(
&cfg,
Duration::from_secs(301),
Some("rebalance error: Broker: Not coordinator"),
)
.expect("the deadline has passed");
match error {
SourceError::Client { class, reason } => {
assert_eq!(class, ErrorClass::Fatal, "{reason}");
assert!(
reason.contains("no partition assignment for 301s"),
"{reason}"
);
assert!(
reason.contains("rebalance error: Broker: Not coordinator"),
"names the last rebalance event: {reason}"
);
assert!(
reason.contains(r#"group "test""#),
"names the group: {reason}"
);
}
other => panic!("expected a client error, got {other:?}"),
}
}
#[test]
fn a_member_inside_the_deadline_keeps_running() {
let mut cfg = test_config();
cfg.assignment_timeout = Duration::from_secs(300);
assert!(
assignment_deadline_error(&cfg, Duration::from_secs(299), Some("a revocation"))
.is_none()
);
assert!(
assignment_deadline_error(&cfg, Duration::from_secs(300), Some("a revocation"))
.is_none(),
"the deadline is exclusive"
);
}
#[test]
fn a_zero_timeout_disables_the_deadline() {
let mut cfg = test_config();
cfg.assignment_timeout = Duration::ZERO;
cfg.startup_timeout = Duration::ZERO;
let a_day = Duration::from_secs(86_400);
assert!(assignment_deadline_error(&cfg, a_day, Some("a revocation")).is_none());
assert!(
assignment_deadline_error(&cfg, a_day, None).is_none(),
"a member still waiting for its first assignment"
);
}
#[test]
fn the_startup_window_has_its_own_deadline_and_message() {
let mut cfg = test_config();
cfg.startup_timeout = Duration::from_secs(30);
cfg.assignment_timeout = Duration::from_secs(300);
assert!(
assignment_deadline_error(&cfg, Duration::from_secs(30), None).is_none(),
"the deadline is exclusive"
);
let error = assignment_deadline_error(&cfg, Duration::from_secs(31), None)
.expect("the startup deadline has passed");
match error {
SourceError::Client { class, reason } => {
assert_eq!(class, ErrorClass::Fatal, "{reason}");
assert!(
reason.contains("no partition assignment within 31s"),
"{reason}"
);
assert!(
reason.contains(r#"topic "orders""#),
"names the topic: {reason}"
);
assert!(
reason.contains(r#"brokers "localhost:9092""#),
"names the brokers: {reason}"
);
}
other => panic!("expected a client error, got {other:?}"),
}
}
#[test]
fn a_loss_before_the_first_assignment_stays_in_the_startup_window() {
let mut source = KafkaSource::new(test_config());
let cp = Checkpointer::new();
source.open(SourceCtx::new(cp.handle())).expect("open");
let opened_at = source
.assignment_wait
.as_ref()
.expect("open arms the startup wait")
.since;
source.note_assignment_loss("a revocation".to_owned());
let wait = source.assignment_wait.as_ref().expect("still waiting");
assert!(wait.cause.is_none(), "the startup window still governs");
assert_eq!(wait.since, opened_at, "the wait still runs from open");
}
#[test]
fn poll_events_reports_an_expired_deadline() {
let mut cfg = test_config();
cfg.assignment_timeout = Duration::from_millis(1);
let mut source = opened_source_with(cfg, &[]);
source.note_assignment_loss("a revocation".to_owned());
match next_outcome(&mut source) {
Err(SourceError::Client { class, reason }) => {
assert_eq!(class, ErrorClass::Fatal, "{reason}");
assert!(reason.contains("no partition assignment for"), "{reason}");
}
other => panic!("expected the deadline error, got {other:?}"),
}
}
#[test]
fn a_revocation_arms_the_deadline() {
let mut source = opened_source(&[(0, 0)]);
push_intent(&source, Intent::Revoke(source.tpl_for([0])));
match next_outcome(&mut source) {
Ok(SourceEvent::LanesRevoked { lanes, barrier }) => {
assert_eq!(lanes, vec![LaneId(0)]);
barrier.arrive(); }
other => panic!("expected LanesRevoked, got {other:?}"),
}
assert_eq!(poll_until_armed(&mut source), "a revocation");
}
#[test]
fn an_accepted_empty_assignment_clears_the_deadline() {
let mut source = opened_source(&[]);
push_error(&source, RDKafkaErrorCode::RebalanceInProgress);
match next_outcome(&mut source) {
Err(SourceError::Client { reason, .. }) => {
assert!(reason.contains("rebalance error"), "{reason}");
}
other => panic!("expected the classified error, got {other:?}"),
}
let cause = poll_until_armed(&mut source);
assert!(
cause.starts_with("rebalance error:"),
"the error arms the deadline and names itself: {cause}"
);
push_intent(&source, Intent::Assign(TopicPartitionList::new()));
poll_until_cleared(&mut source);
}
}
#[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}"
);
}
}
}