Skip to main content

etl_kafka/
config.rs

1//! Kafka source configuration: typed fields plus a validated raw
2//! librdkafka property passthrough.
3
4use etl_core::config::{ComponentConfig, ConfigError};
5use serde::Deserialize;
6use std::collections::BTreeMap;
7use std::time::Duration;
8
9/// Properties the framework owns. Setting them through the passthrough is
10/// rejected at load time with an explanation, because overriding them
11/// silently breaks the framework's delivery guarantees or its threading
12/// model.
13///
14/// librdkafka accepts several property names that write the same underlying
15/// setting (`_RK_C_ALIAS` entries in `rdkafka_conf.c`). Because the
16/// passthrough and the framework's own values are distinct `ClientConfig`
17/// map keys applied in unspecified order, an alias of an owned property would
18/// non-deterministically override it. Every alias of a reserved property is
19/// therefore denied alongside its canonical name. Auditing the reserved set
20/// against librdkafka's config table yields two alias names to add — the
21/// other reserved properties (`group.id`, `enable.auto.offset.store`,
22/// `auto.commit.interval.ms`, `enable.partition.eof`, `statistics.interval.ms`,
23/// `group.protocol`, `partition.assignment.strategy`) have no alias.
24const 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        // librdkafka's deprecated topic-level alias that `enable.auto.commit`
37        // maps to; denied so auto-commit cannot be disabled by the back door.
38        "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        // librdkafka's canonical name for `bootstrap.servers`; both write the
54        // same broker list, so denying only one leaves the framework's
55        // broker list overridable through the other.
56        "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/// Configuration of a [`KafkaSource`](crate::KafkaSource), deserialized
85/// from the pipeline's opaque `source: { kafka: ... }` section.
86#[derive(Clone, Debug, Deserialize, PartialEq)]
87#[serde(deny_unknown_fields)]
88pub struct KafkaSourceConfig {
89    /// Comma-separated bootstrap servers.
90    pub brokers: String,
91    /// The topic to consume. One topic per pipeline: the framework's
92    /// `PartitionId` is the Kafka partition number, which is only unique
93    /// within a single topic.
94    pub topic: String,
95    /// Consumer group id.
96    pub group_id: String,
97    /// How often stored offsets are auto-committed
98    /// (librdkafka `auto.commit.interval.ms`).
99    #[serde(with = "humantime_serde", default = "default_commit_interval")]
100    pub commit_interval: Duration,
101    /// How long to wait for the first partition assignment before the
102    /// source reports a fatal startup error.
103    #[serde(with = "humantime_serde", default = "default_startup_timeout")]
104    pub startup_timeout: Duration,
105    /// Statistics emission interval feeding lag metrics. Zero disables
106    /// statistics.
107    #[serde(with = "humantime_serde", default = "default_statistics_interval")]
108    pub statistics_interval: Duration,
109    /// Raw librdkafka properties, applied verbatim after validation.
110    /// Framework-owned properties (see crate docs) are rejected;
111    /// prefetch backstops (`queued.min.messages`,
112    /// `queued.max.messages.kbytes`) may be tuned here.
113    #[serde(default)]
114    pub rdkafka: BTreeMap<String, String>,
115}
116
117impl KafkaSourceConfig {
118    /// Deserialize and validate from the pipeline's opaque component
119    /// section.
120    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    /// Cross-field validation, including the passthrough denylist.
127    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    /// Build the effective librdkafka client configuration.
164    pub(crate) fn client_config(&self) -> rdkafka::ClientConfig {
165        let mut cc = rdkafka::ClientConfig::new();
166        // User passthrough first: framework-owned settings below always win.
167        for (k, v) in &self.rdkafka {
168            cc.set(k, v);
169        }
170        // Prefetch memory backstop, overridable through the passthrough.
171        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        // The framework's commit mechanism: offsets are stored explicitly
177        // when checkpoint watermarks advance and committed on an interval.
178        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(&section(&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(&section(&body)).unwrap_err();
235            let msg = err.to_string();
236            assert!(msg.contains(key), "error names the key: {msg}");
237        }
238    }
239
240    /// Regression: the librdkafka alias `metadata.broker.list` writes the same
241    /// underlying broker list as `bootstrap.servers`. Left un-denied, a
242    /// passthrough value would race the framework-owned broker list at client
243    /// creation (`ClientConfig` applies its map in unspecified order), so some
244    /// process restarts would silently consume from the wrong cluster.
245    #[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(&section(&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(&section(&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(&section(&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(&section(&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(&section(body)).is_err());
311        }
312    }
313}