1pub mod avro;
5pub mod config;
6mod metadata_error;
7pub mod metrics;
8mod offsets;
9pub mod rebalance;
10pub mod source;
11mod vnode_routing;
12
13pub mod avro_serializer;
15pub mod partitioner;
16pub mod sink;
17pub mod sink_config;
18pub mod sink_metrics;
19
20pub mod schema_registry;
22
23pub 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
33pub 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#[cfg(feature = "testing")]
44pub mod testing {
45 use crate::error::ConnectorError;
46
47 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
67pub use schema_registry::{CachedSchema, CompatibilityResult, SchemaRegistryClient, SchemaType};
69
70use std::sync::Arc;
71
72use crate::config::{ConfigKeySpec, ConnectorInfo};
73use crate::registry::ConnectorRegistry;
74
75pub 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 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
108pub 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 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#[allow(clippy::too_many_lines)]
143fn kafka_source_config_keys() -> Vec<ConfigKeySpec> {
144 vec![
145 ConfigKeySpec::required("bootstrap.servers", "Kafka broker addresses"),
147 ConfigKeySpec::required("group.id", "Consumer group identifier"),
148 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 ConfigKeySpec::optional("format", "Data format (json/csv/avro/raw/debezium)", "json"),
161 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 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 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 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 ConfigKeySpec::optional(
249 "include.metadata",
250 "Include _partition/_offset/_timestamp columns",
251 "false",
252 ),
253 ConfigKeySpec::optional("include.headers", "Include _headers column", "false"),
254 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 ConfigKeySpec::optional(
267 "max.deser.error.rate",
268 "Max tolerated deserialization error rate per batch (0.0-1.0)",
269 "0.5",
270 ),
271 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
337fn kafka_sink_config_keys() -> Vec<ConfigKeySpec> {
339 vec![
340 ConfigKeySpec::required("bootstrap.servers", "Kafka broker addresses"),
342 ConfigKeySpec::required("topic", "Target Kafka topic"),
343 ConfigKeySpec::optional("format", "Serialization format (json/csv/avro/raw)", "json"),
345 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 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 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 ConfigKeySpec::optional(
395 "dlq.topic",
396 "Dead letter queue topic for failed records",
397 "",
398 ),
399 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)]
422mod avro_roundtrip_tests {
423 use std::sync::Arc;
424
425 use arrow_array::{
426 BooleanArray, Float32Array, Float64Array, Int32Array, Int64Array, RecordBatch, StringArray,
427 };
428 use arrow_schema::{DataType, Field, Schema, SchemaRef};
429
430 use super::avro::AvroDeserializer;
431 use super::avro_serializer::AvroSerializer;
432 use super::schema_registry::arrow_to_avro_schema;
433 use crate::serde::{RecordDeserializer, RecordSerializer};
434
435 fn roundtrip(batch: &RecordBatch, schema: &SchemaRef) -> RecordBatch {
437 let avro_schema_json =
438 arrow_to_avro_schema(schema, "roundtrip_test").expect("Arrow→Avro schema");
439
440 let ser = AvroSerializer::new(schema.clone(), 1);
441 let records = ser.serialize(batch).expect("serialize");
442
443 let mut deser = AvroDeserializer::new();
444 deser
445 .register_schema(1, &avro_schema_json)
446 .expect("register schema");
447
448 let record_refs: Vec<&[u8]> = records.iter().map(Vec::as_slice).collect();
449 deser
450 .deserialize_batch(&record_refs, schema)
451 .expect("deserialize")
452 }
453
454 #[test]
455 fn test_roundtrip_primitives() {
456 let schema = Arc::new(Schema::new(vec![
457 Field::new("id", DataType::Int64, false),
458 Field::new("name", DataType::Utf8, false),
459 Field::new("price", DataType::Float64, false),
460 ]));
461 let batch = RecordBatch::try_new(
462 schema.clone(),
463 vec![
464 Arc::new(Int64Array::from(vec![1, 2, 3])),
465 Arc::new(StringArray::from(vec!["AAPL", "GOOG", "MSFT"])),
466 Arc::new(Float64Array::from(vec![150.0, 2800.0, 300.0])),
467 ],
468 )
469 .unwrap();
470
471 let result = roundtrip(&batch, &schema);
472 assert_eq!(result.num_rows(), 3);
473 assert_eq!(result.num_columns(), 3);
474
475 let ids = result
476 .column(0)
477 .as_any()
478 .downcast_ref::<Int64Array>()
479 .unwrap();
480 assert_eq!(ids.value(0), 1);
481 assert_eq!(ids.value(1), 2);
482 assert_eq!(ids.value(2), 3);
483
484 let names = result
485 .column(1)
486 .as_any()
487 .downcast_ref::<StringArray>()
488 .unwrap();
489 assert_eq!(names.value(0), "AAPL");
490 assert_eq!(names.value(1), "GOOG");
491 assert_eq!(names.value(2), "MSFT");
492
493 let prices = result
494 .column(2)
495 .as_any()
496 .downcast_ref::<Float64Array>()
497 .unwrap();
498 assert!((prices.value(0) - 150.0).abs() < f64::EPSILON);
499 assert!((prices.value(1) - 2800.0).abs() < f64::EPSILON);
500 }
501
502 #[test]
503 fn test_roundtrip_all_primitive_types() {
504 let schema = Arc::new(Schema::new(vec![
505 Field::new("b", DataType::Boolean, false),
506 Field::new("i32", DataType::Int32, false),
507 Field::new("i64", DataType::Int64, false),
508 Field::new("f32", DataType::Float32, false),
509 Field::new("f64", DataType::Float64, false),
510 Field::new("s", DataType::Utf8, false),
511 ]));
512 let batch = RecordBatch::try_new(
513 schema.clone(),
514 vec![
515 Arc::new(BooleanArray::from(vec![true, false])),
516 Arc::new(Int32Array::from(vec![42, -1])),
517 Arc::new(Int64Array::from(vec![100_000_000, -999])),
518 Arc::new(Float32Array::from(vec![3.14f32, -0.001f32])),
519 Arc::new(Float64Array::from(vec![2.718, 1e10])),
520 Arc::new(StringArray::from(vec!["hello", "world"])),
521 ],
522 )
523 .unwrap();
524
525 let result = roundtrip(&batch, &schema);
526 assert_eq!(result.num_rows(), 2);
527 assert_eq!(result.num_columns(), 6);
528
529 let bools = result
530 .column(0)
531 .as_any()
532 .downcast_ref::<BooleanArray>()
533 .unwrap();
534 assert!(bools.value(0));
535 assert!(!bools.value(1));
536
537 let ints = result
538 .column(1)
539 .as_any()
540 .downcast_ref::<Int32Array>()
541 .unwrap();
542 assert_eq!(ints.value(0), 42);
543 assert_eq!(ints.value(1), -1);
544 }
545
546 #[test]
547 fn test_roundtrip_single_row() {
548 let schema = Arc::new(Schema::new(vec![
549 Field::new("id", DataType::Int64, false),
550 Field::new("val", DataType::Utf8, false),
551 ]));
552 let batch = RecordBatch::try_new(
553 schema.clone(),
554 vec![
555 Arc::new(Int64Array::from(vec![99])),
556 Arc::new(StringArray::from(vec!["single"])),
557 ],
558 )
559 .unwrap();
560
561 let result = roundtrip(&batch, &schema);
562 assert_eq!(result.num_rows(), 1);
563 let val = result
564 .column(1)
565 .as_any()
566 .downcast_ref::<StringArray>()
567 .unwrap();
568 assert_eq!(val.value(0), "single");
569 }
570
571 #[test]
572 fn test_roundtrip_confluent_wire_format() {
573 let schema = Arc::new(Schema::new(vec![
574 Field::new("id", DataType::Int64, false),
575 Field::new("name", DataType::Utf8, false),
576 ]));
577 let batch = RecordBatch::try_new(
578 schema.clone(),
579 vec![
580 Arc::new(Int64Array::from(vec![1, 2])),
581 Arc::new(StringArray::from(vec!["a", "b"])),
582 ],
583 )
584 .unwrap();
585
586 let ser = AvroSerializer::new(schema.clone(), 42);
587 let records = ser.serialize(&batch).unwrap();
588
589 for record in &records {
591 assert!(record.len() >= 5, "record too short");
592 assert_eq!(record[0], 0x00, "magic byte");
593 let schema_id = u32::from_be_bytes([record[1], record[2], record[3], record[4]]);
594 assert_eq!(schema_id, 42, "schema ID in header");
595 }
596
597 let avro_schema_json = arrow_to_avro_schema(&schema, "test").unwrap();
599 let mut deser = AvroDeserializer::new();
600 deser.register_schema(42, &avro_schema_json).unwrap();
601
602 let record_refs: Vec<&[u8]> = records.iter().map(Vec::as_slice).collect();
603 let result = deser.deserialize_batch(&record_refs, &schema).unwrap();
604 assert_eq!(result.num_rows(), 2);
605 }
606
607 #[test]
608 fn test_roundtrip_empty_batch() {
609 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
610 let batch = RecordBatch::new_empty(schema.clone());
611
612 let ser = AvroSerializer::new(schema.clone(), 1);
613 let records = ser.serialize(&batch).unwrap();
614 assert!(records.is_empty());
615 }
616
617 #[test]
618 fn test_roundtrip_many_rows() {
619 let schema = Arc::new(Schema::new(vec![
620 Field::new("idx", DataType::Int64, false),
621 Field::new("label", DataType::Utf8, false),
622 ]));
623 let n = 100;
624 let ids: Vec<i64> = (0..n).collect();
625 let labels: Vec<String> = (0..n).map(|i| format!("row-{i}")).collect();
626 let batch = RecordBatch::try_new(
627 schema.clone(),
628 vec![
629 Arc::new(Int64Array::from(ids)),
630 Arc::new(StringArray::from(labels)),
631 ],
632 )
633 .unwrap();
634
635 let result = roundtrip(&batch, &schema);
636 assert_eq!(result.num_rows(), n as usize);
637
638 let ids = result
639 .column(0)
640 .as_any()
641 .downcast_ref::<Int64Array>()
642 .unwrap();
643 for i in 0..n as usize {
644 assert_eq!(ids.value(i), i as i64);
645 }
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652
653 #[test]
654 fn test_register_kafka_source() {
655 let registry = ConnectorRegistry::new();
656 register_kafka_source(®istry).unwrap();
657
658 let sources = registry.list_sources();
659 assert!(sources.contains(&"kafka".to_string()));
660
661 let info = registry.source_info("kafka");
662 assert!(info.is_some());
663 let info = info.unwrap();
664 assert_eq!(info.name, "kafka");
665 assert!(info.is_source);
666 assert!(!info.is_sink);
667 let required: Vec<&str> = info
668 .config_keys
669 .iter()
670 .filter(|key| key.required)
671 .map(|key| key.key.as_str())
672 .collect();
673 assert!(required.contains(&"bootstrap.servers"));
674 assert!(required.contains(&"group.id"));
675 assert!(!required.contains(&"topic"));
676 assert!(!required.contains(&"topic.pattern"));
677 assert!(info.config_keys.iter().any(|key| key.key == "topic"));
678 assert!(info
679 .config_keys
680 .iter()
681 .any(|key| key.key == "topic.pattern"));
682 for supported in [
683 "schema.registry.subject.name.strategy",
684 "schema.registry.record.name",
685 "schema.registry.discovery.timeout.ms",
686 "max.poll.interval.ms",
687 "broker.commit.on.checkpoint",
688 "reader.channel.capacity",
689 ] {
690 assert!(
691 info.config_keys.iter().any(|key| key.key == supported),
692 "registered Kafka source descriptor omits {supported}"
693 );
694 }
695 }
696
697 #[test]
698 fn test_factory_creates_source() {
699 let registry = ConnectorRegistry::new();
700 register_kafka_source(®istry).unwrap();
701
702 let config = crate::config::ConnectorConfig::new("kafka");
703 let source = registry.create_source(&config, None);
704 assert!(source.is_ok());
705 }
706
707 #[test]
708 fn test_register_kafka_sink() {
709 let registry = ConnectorRegistry::new();
710 register_kafka_sink(®istry).unwrap();
711
712 let sinks = registry.list_sinks();
713 assert!(sinks.contains(&"kafka".to_string()));
714
715 let info = registry.sink_info("kafka");
716 assert!(info.is_some());
717 let info = info.unwrap();
718 assert_eq!(info.name, "kafka");
719 assert!(!info.is_source);
720 assert!(info.is_sink);
721 assert!(info.config_keys.iter().any(|key| key.key == "envelope"));
722 assert!(info.config_keys.iter().any(|key| key.key == "key.column"));
723 assert!(!info
724 .config_keys
725 .iter()
726 .any(|key| key.key == "delivery.guarantee"));
727 }
728
729 #[test]
730 fn test_factory_creates_sink() {
731 let registry = ConnectorRegistry::new();
732 register_kafka_sink(®istry).unwrap();
733
734 let config = crate::config::ConnectorConfig::new("kafka");
735 let sink = registry.create_sink(&config, None);
736 assert!(sink.is_ok());
737 }
738}