laminar_connectors/kafka/config/
source_options.rs1use std::time::Duration;
7
8use crate::config::ConnectorConfig;
9use crate::error::ConnectorError;
10use crate::serde::Format;
11
12use super::{
13 parse_specific_offsets, AssignmentStrategy, GroupProtocol, IsolationLevel,
14 KafkaMetadataColumns, KafkaSourceConfig, LatencyProfile, OffsetReset, SchemaEvolutionStrategy,
15 SecurityProtocol, SrAuth, StartupMode, SubjectNameStrategy, TopicSubscription,
16};
17
18const CLASSIC_GROUP_OPTIONS: [&str; 3] = [
20 "session.timeout.ms",
21 "heartbeat.interval.ms",
22 "partition.assignment.strategy",
23];
24
25impl KafkaSourceConfig {
26 #[allow(deprecated)]
32 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
33 if config.get("replay.order").is_some() {
34 return Err(ConnectorError::ConfigurationError(
35 "Kafka replay.order is unsupported; cross-partition replay ordering belongs to the engine".into(),
36 ));
37 }
38 if config.get("envelope").is_some() {
39 return Err(ConnectorError::ConfigurationError(
40 "Kafka sources have no 'envelope' option: a table source keyed by the message key \
41 (key + value = upsert, key + NULL = delete) is not supported; use \
42 format = 'debezium' for keyed changes"
43 .into(),
44 ));
45 }
46 let mut parsed = Self::default();
47 parsed.parse_connection(config)?;
48 parsed.parse_schema(config)?;
49 parsed.parse_startup_and_fetch(config)?;
50 parsed.parse_group_timing(config)?;
51 parsed.parse_delivery_controls(config)?;
52 parsed.kafka_properties = config.properties_with_prefix("kafka.");
53 parsed.validate()?;
54 Ok(parsed)
55 }
56
57 fn parse_connection(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
58 self.bootstrap_servers = config.require("bootstrap.servers")?.to_string();
59 self.group_id = config.require("group.id")?.to_string();
60 self.subscription = if let Some(pattern) = config.get("topic.pattern") {
61 TopicSubscription::Pattern(pattern.to_string())
62 } else {
63 let topics = config
64 .require("topic")?
65 .split(',')
66 .map(|topic| topic.trim().to_string())
67 .collect();
68 TopicSubscription::Topics(topics)
69 };
70
71 self.security_protocol = config
72 .get("security.protocol")
73 .map_or(Ok(SecurityProtocol::default()), str::parse)?;
74 self.sasl_mechanism = config.get("sasl.mechanism").map(str::parse).transpose()?;
75 self.sasl_username = config.get("sasl.username").map(String::from);
76 self.sasl_password = config.get("sasl.password").map(String::from);
77 self.ssl_ca_location = config.get("ssl.ca.location").map(String::from);
78 self.ssl_certificate_location = config.get("ssl.certificate.location").map(String::from);
79 self.ssl_key_location = config.get("ssl.key.location").map(String::from);
80 self.ssl_key_password = config.get("ssl.key.password").map(String::from);
81 Ok(())
82 }
83
84 fn parse_schema(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
85 self.format = config.get("format").map_or(Ok(Format::Json), |format| {
86 format
87 .parse::<Format>()
88 .map_err(|error| ConnectorError::ConfigurationError(error.to_string()))
89 })?;
90 self.schema_registry_url = config.get("schema.registry.url").map(String::from);
91 self.schema_registry_auth = parse_schema_registry_auth(config)?;
92 self.schema_compatibility = config
93 .get("schema.compatibility")
94 .map(str::parse)
95 .transpose()?;
96 self.schema_evolution_strategy = config
97 .get("schema.evolution.strategy")
98 .map_or(Ok(SchemaEvolutionStrategy::default()), str::parse)?;
99 self.schema_registry_ssl_ca_location = config
100 .get("schema.registry.ssl.ca.location")
101 .map(String::from);
102 self.schema_registry_ssl_certificate_location = config
103 .get("schema.registry.ssl.certificate.location")
104 .map(String::from);
105 self.schema_registry_ssl_key_location = config
106 .get("schema.registry.ssl.key.location")
107 .map(String::from);
108 self.schema_registry_subject_strategy = config
109 .get("schema.registry.subject.name.strategy")
110 .map_or(Ok(SubjectNameStrategy::default()), str::parse)?;
111 self.schema_registry_record_name =
112 config.get("schema.registry.record.name").map(String::from);
113 validate_subject_name_strategy(
114 self.schema_registry_subject_strategy,
115 self.schema_registry_record_name.as_deref(),
116 )?;
117 self.schema_registry_discovery_timeout = config
118 .get_parsed::<u64>("schema.registry.discovery.timeout.ms")?
119 .map_or(Duration::from_secs(10), Duration::from_millis);
120 if let Some(key) = ["include.metadata", "include.headers"]
121 .into_iter()
122 .find(|key| config.get(key).is_some())
123 {
124 return Err(ConnectorError::ConfigurationError(format!(
125 "'{key}' was replaced by metadata.columns (key, topic, partition, offset, \
126 timestamp, timestamp_type, headers)"
127 )));
128 }
129 self.metadata = config.get("metadata.columns").map_or(
130 Ok(KafkaMetadataColumns::default()),
131 KafkaMetadataColumns::parse,
132 )?;
133 Ok(())
134 }
135
136 fn parse_startup_and_fetch(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
137 self.startup_mode = parse_startup_mode(config)?;
138 self.auto_offset_reset = match &self.startup_mode {
139 StartupMode::Earliest => OffsetReset::Earliest,
140 StartupMode::Latest => OffsetReset::Latest,
141 _ => config
142 .get("auto.offset.reset")
143 .map_or(Ok(OffsetReset::Earliest), str::parse)?,
144 };
145 self.isolation_level = config
146 .get("isolation.level")
147 .map_or(Ok(IsolationLevel::default()), str::parse)?;
148 self.latency_profile = config
149 .get("latency.profile")
150 .map_or(Ok(LatencyProfile::default()), str::parse)?;
151 let profile = self.latency_profile.source_defaults();
152 self.max_poll_records = config
153 .get_parsed::<usize>("max.poll.records")?
154 .unwrap_or(profile.max_poll_records);
155 self.partition_assignment_strategy = config
156 .get("partition.assignment.strategy")
157 .map_or(Ok(AssignmentStrategy::Range), str::parse)?;
158 self.fetch_min_bytes = config
159 .get_parsed::<i32>("fetch.min.bytes")?
160 .or(profile.fetch_min_bytes);
161 self.fetch_max_bytes = config.get_parsed::<i32>("fetch.max.bytes")?;
162 self.fetch_max_wait_ms = config
163 .get_parsed::<i32>("fetch.max.wait.ms")?
164 .or(profile.fetch_max_wait_ms);
165 self.max_partition_fetch_bytes = config.get_parsed::<i32>("max.partition.fetch.bytes")?;
166 Ok(())
167 }
168
169 fn parse_group_timing(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
170 self.group_protocol = config
171 .get("group.protocol")
172 .map_or(Ok(GroupProtocol::default()), str::parse)?;
173 if self.group_protocol == GroupProtocol::Consumer {
174 if let Some(key) = CLASSIC_GROUP_OPTIONS
175 .iter()
176 .find(|key| config.get(key).is_some())
177 {
178 return Err(ConnectorError::ConfigurationError(format!(
179 "'{key}' is a classic consumer-group setting; group.protocol = consumer \
180 (KIP-848) assigns partitions and times sessions on the broker"
181 )));
182 }
183 }
184 let session_timeout_ms = config
185 .get_parsed::<u64>("session.timeout.ms")?
186 .unwrap_or(45_000);
187 let heartbeat_interval_ms = config
188 .get_parsed::<u64>("heartbeat.interval.ms")?
189 .unwrap_or(10_000);
190 self.queued_max_messages_kbytes = config
191 .get_parsed::<u32>("queued.max.messages.kbytes")?
192 .unwrap_or(16384);
193 let max_poll_interval_ms = config
194 .get_parsed::<u64>("max.poll.interval.ms")?
195 .unwrap_or(600_000);
196
197 self.session_timeout = Duration::from_millis(session_timeout_ms);
198 self.heartbeat_interval = Duration::from_millis(heartbeat_interval_ms);
199 self.max_poll_interval = Duration::from_millis(max_poll_interval_ms);
200 Ok(())
201 }
202
203 fn parse_delivery_controls(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
204 reject_deprecated_commit_interval(config)?;
205 self.broker_commit_on_checkpoint = config
206 .get_parsed::<bool>("broker.commit.on.checkpoint")?
207 .unwrap_or(true);
208 self.reader_channel_capacity = config
209 .get_parsed::<usize>("reader.channel.capacity")?
210 .unwrap_or(8192);
211 if let Some(bytes) = config.get_parsed::<usize>("reader.channel.max.bytes")? {
212 self.reader_channel_max_bytes = bytes;
213 }
214 if let Some(bytes) = config.get_parsed::<usize>("max.poll.bytes")? {
215 self.max_poll_bytes = bytes;
216 }
217 if let Some(bytes) = config.get_parsed::<usize>("record.max.bytes")? {
218 self.record_max_bytes = bytes;
219 }
220 self.backpressure_high_watermark = config
221 .get_parsed::<f64>("backpressure.high.watermark")?
222 .unwrap_or(0.8);
223 self.backpressure_low_watermark = config
224 .get_parsed::<f64>("backpressure.low.watermark")?
225 .unwrap_or(0.25);
226 self.max_deser_error_rate = config
227 .get_parsed::<f64>("max.deser.error.rate")?
228 .unwrap_or(0.5);
229 Ok(())
230 }
231}
232
233fn parse_schema_registry_auth(config: &ConnectorConfig) -> Result<Option<SrAuth>, ConnectorError> {
234 match (
235 config.get("schema.registry.username"),
236 config.get("schema.registry.password"),
237 ) {
238 (Some(username), Some(password)) => Ok(Some(SrAuth {
239 username: username.to_string(),
240 password: password.to_string(),
241 })),
242 (Some(_), None) | (None, Some(_)) => Err(ConnectorError::ConfigurationError(
243 "schema.registry.username and schema.registry.password must both be set".to_string(),
244 )),
245 (None, None) => Ok(None),
246 }
247}
248
249fn validate_subject_name_strategy(
250 strategy: SubjectNameStrategy,
251 record_name: Option<&str>,
252) -> Result<(), ConnectorError> {
253 if matches!(
254 strategy,
255 SubjectNameStrategy::RecordName | SubjectNameStrategy::TopicRecordName
256 ) && record_name.is_none()
257 {
258 return Err(ConnectorError::ConfigurationError(format!(
259 "schema.registry.subject.name.strategy={strategy} requires schema.registry.record.name"
260 )));
261 }
262 Ok(())
263}
264
265fn parse_startup_mode(config: &ConnectorConfig) -> Result<StartupMode, ConnectorError> {
266 if let Some(offsets) = config.get("startup.specific.offsets") {
267 return Ok(StartupMode::SpecificOffsets(parse_specific_offsets(
268 offsets,
269 )?));
270 }
271 if let Some(timestamp) = config.get("startup.timestamp.ms") {
272 let timestamp = timestamp.parse().map_err(|_| {
273 ConnectorError::ConfigurationError(format!(
274 "invalid startup.timestamp.ms: '{timestamp}'"
275 ))
276 })?;
277 return Ok(StartupMode::Timestamp(timestamp));
278 }
279 config
280 .get("startup.mode")
281 .map_or(Ok(StartupMode::default()), str::parse)
282}
283
284fn reject_deprecated_commit_interval(config: &ConnectorConfig) -> Result<(), ConnectorError> {
285 if config
286 .properties()
287 .contains_key("broker.commit.interval.ms")
288 {
289 return Err(ConnectorError::ConfigurationError(
290 "broker.commit.interval.ms is no longer supported — broker offset commits now happen \
291 on checkpoint completion. Set broker.commit.on.checkpoint=false to disable."
292 .into(),
293 ));
294 }
295 Ok(())
296}