Skip to main content

laminar_connectors/kafka/
mod.rs

1//! Kafka source and sink connectors.
2
3// Source modules
4pub mod avro;
5pub mod config;
6mod metadata_error;
7pub mod metrics;
8mod offsets;
9pub mod rebalance;
10pub mod source;
11mod vnode_routing;
12
13// Sink modules
14pub mod avro_serializer;
15pub mod partitioner;
16pub mod sink;
17pub mod sink_config;
18pub mod sink_metrics;
19
20// Shared modules
21pub mod schema_registry;
22
23// Source re-exports
24pub use avro::AvroDeserializer;
25pub use config::{
26    AssignmentStrategy, CompatibilityLevel, IsolationLevel, KafkaSourceConfig, OffsetReset,
27    SaslMechanism, SchemaEvolutionStrategy, SecurityProtocol, SrAuth, StartupMode,
28    TopicSubscription,
29};
30pub use metrics::KafkaSourceMetrics;
31pub use source::KafkaSource;
32
33// Sink re-exports
34pub use avro_serializer::AvroSerializer;
35pub use partitioner::{
36    KafkaPartitioner, KeyHashPartitioner, RoundRobinPartitioner, StickyPartitioner,
37};
38pub use sink::KafkaSink;
39pub use sink_config::{CompressionType, KafkaSinkConfig, PartitionStrategy};
40pub use sink_metrics::KafkaSinkMetrics;
41
42/// Test-only access to Kafka-specific routing probes.
43#[cfg(feature = "testing")]
44pub mod testing {
45    use crate::error::ConnectorError;
46
47    /// Map a complete Kafka topic inventory to engine vnodes.
48    ///
49    /// # Errors
50    /// Returns a configuration error when the Kafka route identity, partition
51    /// inventory, or vnode count is invalid.
52    pub fn partition_vnodes(
53        source_identity: &str,
54        topic: &str,
55        total_partitions: i32,
56        vnode_count: u32,
57    ) -> Result<Vec<u32>, ConnectorError> {
58        super::vnode_routing::partition_vnodes(
59            source_identity,
60            topic,
61            total_partitions,
62            vnode_count,
63        )
64    }
65}
66
67// Shared re-exports
68pub use schema_registry::{CachedSchema, CompatibilityResult, SchemaRegistryClient, SchemaType};
69
70use std::sync::Arc;
71
72use crate::config::{ConfigKeySpec, ConnectorInfo};
73use crate::registry::ConnectorRegistry;
74
75/// Registers the Kafka source connector with the given registry.
76///
77/// # Errors
78///
79/// Returns an error if a Kafka source factory is already registered or the
80/// registry has been frozen.
81pub fn register_kafka_source(
82    registry: &ConnectorRegistry,
83) -> Result<(), crate::error::ConnectorError> {
84    let info = ConnectorInfo {
85        name: "kafka".to_string(),
86        display_name: "Apache Kafka Source".to_string(),
87        version: env!("CARGO_PKG_VERSION").to_string(),
88        is_source: true,
89        is_sink: false,
90        config_keys: kafka_source_config_keys(),
91    };
92
93    registry.register_source(
94        "kafka",
95        info,
96        Arc::new(|registry: Option<&Arc<prometheus::Registry>>| {
97            // Empty schema — filled in by discover_schema / open() / SQL DDL columns.
98            let empty = Arc::new(arrow_schema::Schema::empty());
99            Ok(Box::new(KafkaSource::new(
100                empty,
101                KafkaSourceConfig::default(),
102                registry.map(Arc::as_ref),
103            )))
104        }),
105    )
106}
107
108/// Registers the Kafka sink connector with the given registry.
109///
110/// # Errors
111///
112/// Returns an error if a Kafka sink factory is already registered or the
113/// registry has been frozen.
114pub fn register_kafka_sink(
115    registry: &ConnectorRegistry,
116) -> Result<(), crate::error::ConnectorError> {
117    let info = ConnectorInfo {
118        name: "kafka".to_string(),
119        display_name: "Apache Kafka Sink".to_string(),
120        version: env!("CARGO_PKG_VERSION").to_string(),
121        is_source: false,
122        is_sink: true,
123        config_keys: kafka_sink_config_keys(),
124    };
125
126    registry.register_sink(
127        "kafka",
128        info,
129        Arc::new(|_config, registry: Option<&Arc<prometheus::Registry>>| {
130            // Empty schema — the sink's schema is bound from the upstream query at build time.
131            let empty = Arc::new(arrow_schema::Schema::empty());
132            Ok(Box::new(KafkaSink::new(
133                empty,
134                KafkaSinkConfig::default(),
135                registry.map(Arc::as_ref),
136            )))
137        }),
138    )
139}
140
141/// Returns the configuration key specifications for the Kafka source.
142#[allow(clippy::too_many_lines)]
143fn kafka_source_config_keys() -> Vec<ConfigKeySpec> {
144    vec![
145        // Required
146        ConfigKeySpec::required("bootstrap.servers", "Kafka broker addresses"),
147        ConfigKeySpec::required("group.id", "Consumer group identifier"),
148        // The key schema cannot express alternatives; parsing requires one of these two.
149        ConfigKeySpec::optional(
150            "topic",
151            "Comma-separated topics (required unless topic.pattern is set)",
152            "",
153        ),
154        ConfigKeySpec::optional(
155            "topic.pattern",
156            "Topic regex (required unless topic is set)",
157            "",
158        ),
159        // Format
160        ConfigKeySpec::optional("format", "Data format (json/csv/avro/raw/debezium)", "json"),
161        // Security
162        ConfigKeySpec::optional(
163            "security.protocol",
164            "Security protocol (plaintext/ssl/sasl_plaintext/sasl_ssl)",
165            "plaintext",
166        ),
167        ConfigKeySpec::optional(
168            "sasl.mechanism",
169            "SASL mechanism (PLAIN/SCRAM-SHA-256/SCRAM-SHA-512/GSSAPI/OAUTHBEARER)",
170            "",
171        ),
172        ConfigKeySpec::optional("sasl.username", "SASL username for PLAIN/SCRAM", ""),
173        ConfigKeySpec::optional("sasl.password", "SASL password for PLAIN/SCRAM", ""),
174        ConfigKeySpec::optional("ssl.ca.location", "SSL CA certificate file path", ""),
175        ConfigKeySpec::optional(
176            "ssl.certificate.location",
177            "Client SSL certificate file path",
178            "",
179        ),
180        ConfigKeySpec::optional("ssl.key.location", "Client SSL private key file path", ""),
181        ConfigKeySpec::optional("ssl.key.password", "Password for encrypted SSL key", ""),
182        // Consumer tuning
183        ConfigKeySpec::optional(
184            "startup.mode",
185            "Startup mode (group-offsets/earliest/latest)",
186            "group-offsets",
187        ),
188        ConfigKeySpec::optional(
189            "startup.specific.offsets",
190            "Start from specific offsets (format: 'partition:offset,...')",
191            "",
192        ),
193        ConfigKeySpec::optional(
194            "startup.timestamp.ms",
195            "Start from timestamp (milliseconds since epoch)",
196            "",
197        ),
198        ConfigKeySpec::optional(
199            "auto.offset.reset",
200            "Fallback when no committed offset (earliest/latest/none)",
201            "earliest",
202        ),
203        ConfigKeySpec::optional(
204            "isolation.level",
205            "Transaction isolation (read_uncommitted/read_committed)",
206            "read_committed",
207        ),
208        ConfigKeySpec::optional("max.poll.records", "Max records per poll", "1000"),
209        ConfigKeySpec::optional(
210            "partition.assignment.strategy",
211            "Partition assignment (range/roundrobin/cooperative-sticky)",
212            "range",
213        ),
214        // Consumer group timing
215        ConfigKeySpec::optional(
216            "session.timeout.ms",
217            "Consumer session timeout in milliseconds (production-safe default)",
218            "45000",
219        ),
220        ConfigKeySpec::optional(
221            "heartbeat.interval.ms",
222            "Consumer heartbeat interval in milliseconds",
223            "10000",
224        ),
225        ConfigKeySpec::optional(
226            "queued.max.messages.kbytes",
227            "Max per-partition pre-fetch queue size in kbytes",
228            "16384",
229        ),
230        // Fetch tuning
231        ConfigKeySpec::optional("fetch.min.bytes", "Minimum bytes per fetch request", "1"),
232        ConfigKeySpec::optional(
233            "fetch.max.bytes",
234            "Maximum bytes per fetch request",
235            "52428800",
236        ),
237        ConfigKeySpec::optional(
238            "fetch.max.wait.ms",
239            "Max wait time for fetch.min.bytes",
240            "500",
241        ),
242        ConfigKeySpec::optional(
243            "max.partition.fetch.bytes",
244            "Max bytes per partition per fetch",
245            "1048576",
246        ),
247        // Metadata
248        ConfigKeySpec::optional(
249            "include.metadata",
250            "Include _partition/_offset/_timestamp columns",
251            "false",
252        ),
253        ConfigKeySpec::optional("include.headers", "Include _headers column", "false"),
254        // Backpressure
255        ConfigKeySpec::optional(
256            "backpressure.high.watermark",
257            "Channel fill ratio to pause",
258            "0.8",
259        ),
260        ConfigKeySpec::optional(
261            "backpressure.low.watermark",
262            "Channel fill ratio to resume",
263            "0.25",
264        ),
265        // Error handling
266        ConfigKeySpec::optional(
267            "max.deser.error.rate",
268            "Max tolerated deserialization error rate per batch (0.0-1.0)",
269            "0.5",
270        ),
271        // Schema Registry
272        ConfigKeySpec::optional(
273            "schema.registry.url",
274            "Confluent Schema Registry URL (required for Avro)",
275            "",
276        ),
277        ConfigKeySpec::optional("schema.registry.username", "Schema Registry username", ""),
278        ConfigKeySpec::optional("schema.registry.password", "Schema Registry password", ""),
279        ConfigKeySpec::optional(
280            "schema.registry.ssl.ca.location",
281            "Schema Registry SSL CA cert path",
282            "",
283        ),
284        ConfigKeySpec::optional(
285            "schema.registry.ssl.certificate.location",
286            "Schema Registry SSL client cert path",
287            "",
288        ),
289        ConfigKeySpec::optional(
290            "schema.registry.ssl.key.location",
291            "Schema Registry SSL client key path",
292            "",
293        ),
294        ConfigKeySpec::optional(
295            "schema.compatibility",
296            "Schema compatibility level override",
297            "",
298        ),
299        ConfigKeySpec::optional(
300            "schema.evolution.strategy",
301            "Runtime schema evolution handling (log/reject/ignore)",
302            "log",
303        ),
304        ConfigKeySpec::optional(
305            "schema.registry.subject.name.strategy",
306            "Schema Registry subject naming (topic-name/record-name/topic-record-name)",
307            "topic-name",
308        ),
309        ConfigKeySpec::optional(
310            "schema.registry.record.name",
311            "Avro record name for record-based subject naming",
312            "",
313        ),
314        ConfigKeySpec::optional(
315            "schema.registry.discovery.timeout.ms",
316            "Schema discovery timeout in milliseconds",
317            "10000",
318        ),
319        ConfigKeySpec::optional(
320            "max.poll.interval.ms",
321            "Maximum interval between consumer polls in milliseconds",
322            "600000",
323        ),
324        ConfigKeySpec::optional(
325            "broker.commit.on.checkpoint",
326            "Commit broker offsets after checkpoint completion",
327            "true",
328        ),
329        ConfigKeySpec::optional(
330            "reader.channel.capacity",
331            "Bounded reader channel capacity in records",
332            "8192",
333        ),
334    ]
335}
336
337/// Returns the configuration key specifications for the Kafka sink.
338fn kafka_sink_config_keys() -> Vec<ConfigKeySpec> {
339    vec![
340        // Required
341        ConfigKeySpec::required("bootstrap.servers", "Kafka broker addresses"),
342        ConfigKeySpec::required("topic", "Target Kafka topic"),
343        // Format
344        ConfigKeySpec::optional("format", "Serialization format (json/csv/avro/raw)", "json"),
345        // Security
346        ConfigKeySpec::optional(
347            "security.protocol",
348            "Security protocol (plaintext/ssl/sasl_plaintext/sasl_ssl)",
349            "plaintext",
350        ),
351        ConfigKeySpec::optional(
352            "sasl.mechanism",
353            "SASL mechanism (PLAIN/SCRAM-SHA-256/SCRAM-SHA-512/GSSAPI/OAUTHBEARER)",
354            "",
355        ),
356        ConfigKeySpec::optional("sasl.username", "SASL username for PLAIN/SCRAM", ""),
357        ConfigKeySpec::optional("sasl.password", "SASL password for PLAIN/SCRAM", ""),
358        ConfigKeySpec::optional("ssl.ca.location", "SSL CA certificate file path", ""),
359        ConfigKeySpec::optional(
360            "ssl.certificate.location",
361            "Client SSL certificate file path",
362            "",
363        ),
364        ConfigKeySpec::optional("ssl.key.location", "Client SSL private key file path", ""),
365        ConfigKeySpec::optional("ssl.key.password", "Password for encrypted SSL key", ""),
366        ConfigKeySpec::optional(
367            "max.in.flight.requests",
368            "Maximum in-flight producer requests per connection (1-5)",
369            "5",
370        ),
371        ConfigKeySpec::optional(
372            "delivery.timeout.ms",
373            "Delivery timeout in milliseconds",
374            "120000",
375        ),
376        // Partitioning
377        ConfigKeySpec::optional("key.column", "Column name to use as Kafka message key", ""),
378        ConfigKeySpec::optional("envelope", "Output envelope (append/upsert)", "append"),
379        ConfigKeySpec::optional(
380            "partitioner",
381            "Partitioning strategy (key-hash/round-robin/sticky)",
382            "key-hash",
383        ),
384        // Batching & Compression
385        ConfigKeySpec::optional("linger.ms", "Producer linger time in milliseconds", "5"),
386        ConfigKeySpec::optional("batch.size", "Producer batch size in bytes", "16384"),
387        ConfigKeySpec::optional("batch.num.messages", "Max messages per batch", "10000"),
388        ConfigKeySpec::optional(
389            "compression.type",
390            "Compression (none/gzip/snappy/lz4/zstd)",
391            "none",
392        ),
393        // Error Handling
394        ConfigKeySpec::optional(
395            "dlq.topic",
396            "Dead letter queue topic for failed records",
397            "",
398        ),
399        // Schema Registry
400        ConfigKeySpec::optional(
401            "schema.registry.url",
402            "Confluent Schema Registry URL (required for Avro)",
403            "",
404        ),
405        ConfigKeySpec::optional("schema.registry.username", "Schema Registry username", ""),
406        ConfigKeySpec::optional("schema.registry.password", "Schema Registry password", ""),
407        ConfigKeySpec::optional(
408            "schema.registry.ssl.ca.location",
409            "Schema Registry SSL CA cert path",
410            "",
411        ),
412        ConfigKeySpec::optional(
413            "schema.compatibility",
414            "Schema compatibility level override",
415            "",
416        ),
417    ]
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn test_register_kafka_source() {
426        let registry = ConnectorRegistry::new();
427        register_kafka_source(&registry).unwrap();
428
429        let sources = registry.list_sources();
430        assert!(sources.contains(&"kafka".to_string()));
431
432        let info = registry.source_info("kafka");
433        assert!(info.is_some());
434        let info = info.unwrap();
435        assert_eq!(info.name, "kafka");
436        assert!(info.is_source);
437        assert!(!info.is_sink);
438        let required: Vec<&str> = info
439            .config_keys
440            .iter()
441            .filter(|key| key.required)
442            .map(|key| key.key.as_str())
443            .collect();
444        assert!(required.contains(&"bootstrap.servers"));
445        assert!(required.contains(&"group.id"));
446        assert!(!required.contains(&"topic"));
447        assert!(!required.contains(&"topic.pattern"));
448        assert!(info.config_keys.iter().any(|key| key.key == "topic"));
449        assert!(info
450            .config_keys
451            .iter()
452            .any(|key| key.key == "topic.pattern"));
453        for supported in [
454            "schema.registry.subject.name.strategy",
455            "schema.registry.record.name",
456            "schema.registry.discovery.timeout.ms",
457            "max.poll.interval.ms",
458            "broker.commit.on.checkpoint",
459            "reader.channel.capacity",
460        ] {
461            assert!(
462                info.config_keys.iter().any(|key| key.key == supported),
463                "registered Kafka source descriptor omits {supported}"
464            );
465        }
466    }
467
468    #[test]
469    fn test_factory_creates_source() {
470        let registry = ConnectorRegistry::new();
471        register_kafka_source(&registry).unwrap();
472
473        let config = crate::config::ConnectorConfig::new("kafka");
474        let source = registry.create_source(&config, None);
475        assert!(source.is_ok());
476    }
477
478    #[test]
479    fn test_register_kafka_sink() {
480        let registry = ConnectorRegistry::new();
481        register_kafka_sink(&registry).unwrap();
482
483        let sinks = registry.list_sinks();
484        assert!(sinks.contains(&"kafka".to_string()));
485
486        let info = registry.sink_info("kafka");
487        assert!(info.is_some());
488        let info = info.unwrap();
489        assert_eq!(info.name, "kafka");
490        assert!(!info.is_source);
491        assert!(info.is_sink);
492        assert!(info.config_keys.iter().any(|key| key.key == "envelope"));
493        assert!(info.config_keys.iter().any(|key| key.key == "key.column"));
494        assert!(!info
495            .config_keys
496            .iter()
497            .any(|key| key.key == "delivery.guarantee"));
498    }
499
500    #[test]
501    fn test_factory_creates_sink() {
502        let registry = ConnectorRegistry::new();
503        register_kafka_sink(&registry).unwrap();
504
505        let config = crate::config::ConnectorConfig::new("kafka");
506        let sink = registry.create_sink(&config, None);
507        assert!(sink.is_ok());
508    }
509}