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