use serde::Deserialize;
use spate_core::config::{ComponentConfig, ConfigError};
use std::collections::BTreeMap;
use std::time::Duration;
const DENYLIST: &[(&str, &str)] = &[
(
"enable.auto.offset.store",
"the framework stores offsets itself when checkpoint watermarks \
advance; overriding this breaks at-least-once delivery",
),
(
"enable.auto.commit",
"interval auto-commit of framework-stored offsets is the commit \
mechanism; disabling it means nothing is ever committed",
),
(
"auto.commit.enable",
"deprecated librdkafka alias of `enable.auto.commit`; interval \
auto-commit of framework-stored offsets is the commit mechanism, \
disabling it means nothing is ever committed",
),
(
"auto.commit.interval.ms",
"owned by the typed `commit_interval` field",
),
(
"enable.partition.eof",
"EOF events would pollute the partition queues the pipeline polls",
),
("bootstrap.servers", "owned by the typed `brokers` field"),
(
"metadata.broker.list",
"librdkafka alias of `bootstrap.servers`, owned by the typed \
`brokers` field",
),
("group.id", "owned by the typed `group_id` field"),
(
"statistics.interval.ms",
"owned by the typed `statistics_interval` field",
),
(
"group.protocol",
"only the classic consumer group protocol is supported today \
(eager assignment is a framework invariant)",
),
];
fn default_commit_interval() -> Duration {
Duration::from_secs(5)
}
fn default_startup_timeout() -> Duration {
Duration::from_secs(30)
}
fn default_statistics_interval() -> Duration {
Duration::from_secs(5)
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct KafkaSourceConfig {
pub brokers: String,
pub topic: String,
pub group_id: String,
#[serde(with = "humantime_serde", default = "default_commit_interval")]
pub commit_interval: Duration,
#[serde(with = "humantime_serde", default = "default_startup_timeout")]
pub startup_timeout: Duration,
#[serde(with = "humantime_serde", default = "default_statistics_interval")]
pub statistics_interval: Duration,
#[serde(default)]
pub rdkafka: BTreeMap<String, String>,
}
impl KafkaSourceConfig {
pub fn from_component_config(section: &ComponentConfig) -> Result<Self, ConfigError> {
let cfg: KafkaSourceConfig = section.deserialize_into()?;
cfg.validate()?;
Ok(cfg)
}
pub fn validate(&self) -> Result<(), ConfigError> {
if self.brokers.trim().is_empty() {
return Err(ConfigError::Validation(
"source.kafka.brokers must not be empty".into(),
));
}
if self.topic.trim().is_empty() {
return Err(ConfigError::Validation(
"source.kafka.topic must not be empty".into(),
));
}
if self.group_id.trim().is_empty() {
return Err(ConfigError::Validation(
"source.kafka.group_id must not be empty".into(),
));
}
for (key, why) in DENYLIST {
if self.rdkafka.contains_key(*key) {
return Err(ConfigError::Validation(format!(
"source.kafka.rdkafka.\"{key}\" cannot be overridden: {why}"
)));
}
}
crate::security::check_tls_feature(&self.rdkafka, "source.kafka")?;
if let Some(strategy) = self.rdkafka.get("partition.assignment.strategy")
&& strategy.contains("cooperative")
{
return Err(ConfigError::Validation(
"source.kafka.rdkafka.\"partition.assignment.strategy\": \
cooperative assignment is not supported today; the framework \
relies on eager (full) rebalances"
.into(),
));
}
Ok(())
}
pub(crate) fn client_config(&self) -> rdkafka::ClientConfig {
let mut cc = rdkafka::ClientConfig::new();
for (k, v) in &self.rdkafka {
cc.set(k, v);
}
cc.set("bootstrap.servers", &self.brokers);
cc.set("group.id", &self.group_id);
cc.set("enable.auto.offset.store", "false");
cc.set("enable.auto.commit", "true");
cc.set(
"auto.commit.interval.ms",
self.commit_interval.as_millis().to_string(),
);
cc.set("enable.partition.eof", "false");
if !self.statistics_interval.is_zero() {
cc.set(
"statistics.interval.ms",
self.statistics_interval.as_millis().to_string(),
);
}
cc
}
}
#[cfg(test)]
mod tests {
use super::*;
use spate_core::config::ComponentConfig;
fn section(body: &str) -> ComponentConfig {
let yaml = format!("kafka:\n{body}");
let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
ComponentConfig::new("kafka", value["kafka"].clone())
}
fn minimal() -> String {
" brokers: localhost:9092\n topic: orders\n group_id: spate\n".to_string()
}
#[test]
fn minimal_config_gets_documented_defaults() {
let cfg = KafkaSourceConfig::from_component_config(§ion(&minimal())).unwrap();
assert_eq!(cfg.commit_interval, Duration::from_secs(5));
assert_eq!(cfg.startup_timeout, Duration::from_secs(30));
assert_eq!(cfg.statistics_interval, Duration::from_secs(5));
assert!(cfg.rdkafka.is_empty());
}
#[test]
fn denylisted_properties_are_rejected_with_reasons() {
for key in [
"enable.auto.offset.store",
"enable.auto.commit",
"auto.commit.enable",
"auto.commit.interval.ms",
"enable.partition.eof",
"bootstrap.servers",
"metadata.broker.list",
"group.id",
"statistics.interval.ms",
"group.protocol",
] {
let body = format!("{} rdkafka:\n \"{key}\": \"x\"\n", minimal());
let err = KafkaSourceConfig::from_component_config(§ion(&body)).unwrap_err();
let msg = err.to_string();
assert!(msg.contains(key), "error names the key: {msg}");
}
}
#[test]
fn broker_list_alias_cannot_override_framework_brokers() {
let body = format!(
"{} rdkafka:\n metadata.broker.list: \"staging-kafka:9092\"\n",
minimal()
);
let err = KafkaSourceConfig::from_component_config(§ion(&body)).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("metadata.broker.list"),
"error names the key: {msg}"
);
assert!(
msg.contains("bootstrap.servers"),
"error explains the alias: {msg}"
);
}
#[test]
fn cooperative_assignment_is_rejected() {
let body = format!(
"{} rdkafka:\n partition.assignment.strategy: cooperative-sticky\n",
minimal()
);
let err = KafkaSourceConfig::from_component_config(§ion(&body)).unwrap_err();
assert!(err.to_string().contains("cooperative"));
}
#[test]
fn passthrough_survives_and_framework_settings_win() {
let body = format!(
"{} rdkafka:\n fetch.message.max.bytes: \"1048576\"\n queued.min.messages: \"5000\"\n",
minimal()
);
let cfg = KafkaSourceConfig::from_component_config(§ion(&body)).unwrap();
let cc = cfg.client_config();
assert_eq!(
cc.get("fetch.message.max.bytes"),
Some("1048576"),
"passthrough applies"
);
assert_eq!(
cc.get("queued.min.messages"),
Some("5000"),
"prefetch depth is tunable"
);
assert_eq!(cc.get("enable.auto.offset.store"), Some("false"));
assert_eq!(cc.get("enable.auto.commit"), Some("true"));
assert_eq!(cc.get("auto.commit.interval.ms"), Some("5000"));
assert_eq!(cc.get("enable.partition.eof"), Some("false"));
}
#[test]
fn prefetch_is_left_at_the_librdkafka_default() {
let cfg = KafkaSourceConfig::from_component_config(§ion(&minimal())).unwrap();
let cc = cfg.client_config();
assert_eq!(
cc.get("queued.min.messages"),
None,
"the framework must not pin prefetch depth"
);
assert_eq!(cc.get("queued.max.messages.kbytes"), None);
}
#[test]
fn tls_config_matches_build_capability() {
for sec in [
" security.protocol: ssl\n",
" security.protocol: sasl_ssl\n sasl.mechanism: SCRAM-SHA-256\n \
sasl.username: svc\n sasl.password: secret\n",
] {
let body = format!("{} rdkafka:\n{sec}", minimal());
let parsed = KafkaSourceConfig::from_component_config(§ion(&body));
if cfg!(feature = "tls") {
let cfg = parsed.expect("tls build accepts a security config");
let cc = cfg.client_config();
assert!(
cc.get("security.protocol").is_some(),
"security passthrough survives into the client (not denylisted)"
);
let consumer: rdkafka::consumer::BaseConsumer = cc
.create()
.expect("SSL/SASL compiled in: consumer creation succeeds");
drop(consumer);
} else {
let err = parsed.expect_err("non-tls build rejects a security config");
assert!(err.to_string().contains("kafka-tls"), "actionable: {err}");
}
}
}
#[test]
fn unknown_fields_are_rejected() {
let body = format!("{} topics: [a, b]\n", minimal());
assert!(KafkaSourceConfig::from_component_config(§ion(&body)).is_err());
}
#[test]
fn empty_required_fields_error_clearly() {
for body in [
" brokers: \"\"\n topic: t\n group_id: g\n",
" brokers: b\n topic: \"\"\n group_id: g\n",
" brokers: b\n topic: t\n group_id: \"\"\n",
] {
assert!(KafkaSourceConfig::from_component_config(§ion(body)).is_err());
}
}
}