1use etl_core::config::{ComponentConfig, ConfigError};
5use serde::Deserialize;
6use std::collections::BTreeMap;
7use std::time::Duration;
8
9const DENYLIST: &[(&str, &str)] = &[
25 (
26 "enable.auto.offset.store",
27 "the framework stores offsets itself when checkpoint watermarks \
28 advance; overriding this breaks at-least-once delivery",
29 ),
30 (
31 "enable.auto.commit",
32 "interval auto-commit of framework-stored offsets is the commit \
33 mechanism; disabling it means nothing is ever committed",
34 ),
35 (
36 "auto.commit.enable",
39 "deprecated librdkafka alias of `enable.auto.commit`; interval \
40 auto-commit of framework-stored offsets is the commit mechanism, \
41 disabling it means nothing is ever committed",
42 ),
43 (
44 "auto.commit.interval.ms",
45 "owned by the typed `commit_interval` field",
46 ),
47 (
48 "enable.partition.eof",
49 "EOF events would pollute the partition queues the pipeline polls",
50 ),
51 ("bootstrap.servers", "owned by the typed `brokers` field"),
52 (
53 "metadata.broker.list",
57 "librdkafka alias of `bootstrap.servers`, owned by the typed \
58 `brokers` field",
59 ),
60 ("group.id", "owned by the typed `group_id` field"),
61 (
62 "statistics.interval.ms",
63 "owned by the typed `statistics_interval` field",
64 ),
65 (
66 "group.protocol",
67 "only the classic consumer group protocol is supported today \
68 (eager assignment is a framework invariant)",
69 ),
70];
71
72fn default_commit_interval() -> Duration {
73 Duration::from_secs(5)
74}
75
76fn default_startup_timeout() -> Duration {
77 Duration::from_secs(30)
78}
79
80fn default_statistics_interval() -> Duration {
81 Duration::from_secs(5)
82}
83
84#[derive(Clone, Debug, Deserialize, PartialEq)]
87#[serde(deny_unknown_fields)]
88pub struct KafkaSourceConfig {
89 pub brokers: String,
91 pub topic: String,
95 pub group_id: String,
97 #[serde(with = "humantime_serde", default = "default_commit_interval")]
100 pub commit_interval: Duration,
101 #[serde(with = "humantime_serde", default = "default_startup_timeout")]
104 pub startup_timeout: Duration,
105 #[serde(with = "humantime_serde", default = "default_statistics_interval")]
108 pub statistics_interval: Duration,
109 #[serde(default)]
114 pub rdkafka: BTreeMap<String, String>,
115}
116
117impl KafkaSourceConfig {
118 pub fn from_component_config(section: &ComponentConfig) -> Result<Self, ConfigError> {
121 let cfg: KafkaSourceConfig = section.deserialize_into()?;
122 cfg.validate()?;
123 Ok(cfg)
124 }
125
126 pub fn validate(&self) -> Result<(), ConfigError> {
128 if self.brokers.trim().is_empty() {
129 return Err(ConfigError::Validation(
130 "source.kafka.brokers must not be empty".into(),
131 ));
132 }
133 if self.topic.trim().is_empty() {
134 return Err(ConfigError::Validation(
135 "source.kafka.topic must not be empty".into(),
136 ));
137 }
138 if self.group_id.trim().is_empty() {
139 return Err(ConfigError::Validation(
140 "source.kafka.group_id must not be empty".into(),
141 ));
142 }
143 for (key, why) in DENYLIST {
144 if self.rdkafka.contains_key(*key) {
145 return Err(ConfigError::Validation(format!(
146 "source.kafka.rdkafka.\"{key}\" cannot be overridden: {why}"
147 )));
148 }
149 }
150 if let Some(strategy) = self.rdkafka.get("partition.assignment.strategy")
151 && strategy.contains("cooperative")
152 {
153 return Err(ConfigError::Validation(
154 "source.kafka.rdkafka.\"partition.assignment.strategy\": \
155 cooperative assignment is not supported today; the framework \
156 relies on eager (full) rebalances"
157 .into(),
158 ));
159 }
160 Ok(())
161 }
162
163 pub(crate) fn client_config(&self) -> rdkafka::ClientConfig {
165 let mut cc = rdkafka::ClientConfig::new();
166 for (k, v) in &self.rdkafka {
168 cc.set(k, v);
169 }
170 if !self.rdkafka.contains_key("queued.min.messages") {
172 cc.set("queued.min.messages", "1000");
173 }
174 cc.set("bootstrap.servers", &self.brokers);
175 cc.set("group.id", &self.group_id);
176 cc.set("enable.auto.offset.store", "false");
179 cc.set("enable.auto.commit", "true");
180 cc.set(
181 "auto.commit.interval.ms",
182 self.commit_interval.as_millis().to_string(),
183 );
184 cc.set("enable.partition.eof", "false");
185 if !self.statistics_interval.is_zero() {
186 cc.set(
187 "statistics.interval.ms",
188 self.statistics_interval.as_millis().to_string(),
189 );
190 }
191 cc
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use etl_core::config::ComponentConfig;
199
200 fn section(body: &str) -> ComponentConfig {
201 let yaml = format!("kafka:\n{body}");
202 let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
203 ComponentConfig::new("kafka", value["kafka"].clone())
204 }
205
206 fn minimal() -> String {
207 " brokers: localhost:9092\n topic: orders\n group_id: etl\n".to_string()
208 }
209
210 #[test]
211 fn minimal_config_gets_documented_defaults() {
212 let cfg = KafkaSourceConfig::from_component_config(§ion(&minimal())).unwrap();
213 assert_eq!(cfg.commit_interval, Duration::from_secs(5));
214 assert_eq!(cfg.startup_timeout, Duration::from_secs(30));
215 assert_eq!(cfg.statistics_interval, Duration::from_secs(5));
216 assert!(cfg.rdkafka.is_empty());
217 }
218
219 #[test]
220 fn denylisted_properties_are_rejected_with_reasons() {
221 for key in [
222 "enable.auto.offset.store",
223 "enable.auto.commit",
224 "auto.commit.enable",
225 "auto.commit.interval.ms",
226 "enable.partition.eof",
227 "bootstrap.servers",
228 "metadata.broker.list",
229 "group.id",
230 "statistics.interval.ms",
231 "group.protocol",
232 ] {
233 let body = format!("{} rdkafka:\n \"{key}\": \"x\"\n", minimal());
234 let err = KafkaSourceConfig::from_component_config(§ion(&body)).unwrap_err();
235 let msg = err.to_string();
236 assert!(msg.contains(key), "error names the key: {msg}");
237 }
238 }
239
240 #[test]
246 fn broker_list_alias_cannot_override_framework_brokers() {
247 let body = format!(
248 "{} rdkafka:\n metadata.broker.list: \"staging-kafka:9092\"\n",
249 minimal()
250 );
251 let err = KafkaSourceConfig::from_component_config(§ion(&body)).unwrap_err();
252 let msg = err.to_string();
253 assert!(
254 msg.contains("metadata.broker.list"),
255 "error names the key: {msg}"
256 );
257 assert!(
258 msg.contains("bootstrap.servers"),
259 "error explains the alias: {msg}"
260 );
261 }
262
263 #[test]
264 fn cooperative_assignment_is_rejected() {
265 let body = format!(
266 "{} rdkafka:\n partition.assignment.strategy: cooperative-sticky\n",
267 minimal()
268 );
269 let err = KafkaSourceConfig::from_component_config(§ion(&body)).unwrap_err();
270 assert!(err.to_string().contains("cooperative"));
271 }
272
273 #[test]
274 fn passthrough_survives_and_framework_settings_win() {
275 let body = format!(
276 "{} rdkafka:\n fetch.message.max.bytes: \"1048576\"\n queued.min.messages: \"5000\"\n",
277 minimal()
278 );
279 let cfg = KafkaSourceConfig::from_component_config(§ion(&body)).unwrap();
280 let cc = cfg.client_config();
281 assert_eq!(
282 cc.get("fetch.message.max.bytes"),
283 Some("1048576"),
284 "passthrough applies"
285 );
286 assert_eq!(
287 cc.get("queued.min.messages"),
288 Some("5000"),
289 "backstop is overridable"
290 );
291 assert_eq!(cc.get("enable.auto.offset.store"), Some("false"));
292 assert_eq!(cc.get("enable.auto.commit"), Some("true"));
293 assert_eq!(cc.get("auto.commit.interval.ms"), Some("5000"));
294 assert_eq!(cc.get("enable.partition.eof"), Some("false"));
295 }
296
297 #[test]
298 fn unknown_fields_are_rejected() {
299 let body = format!("{} topics: [a, b]\n", minimal());
300 assert!(KafkaSourceConfig::from_component_config(§ion(&body)).is_err());
301 }
302
303 #[test]
304 fn empty_required_fields_error_clearly() {
305 for body in [
306 " brokers: \"\"\n topic: t\n group_id: g\n",
307 " brokers: b\n topic: \"\"\n group_id: g\n",
308 " brokers: b\n topic: t\n group_id: \"\"\n",
309 ] {
310 assert!(KafkaSourceConfig::from_component_config(§ion(body)).is_err());
311 }
312 }
313}