1use std::collections::HashSet;
4
5use async_trait::async_trait;
6use bytes::Bytes;
7use krabka_client_core::security::ClientSecurity;
8use krabka_client_producer::{Acks, DeliveryHandle, Header, Producer, ProducerRecord};
9use krabka_connect::{CheckpointStore, ConnectError, ConnectRecord, Sink, SourceOffset};
10use krabka_replicator::config::ReplicationFactor;
11use krabka_units::{ByteSize, convert::ByteSizeExt as _};
12
13use crate::metrics::WorkerMetrics;
14
15pub const CHECKPOINT_TOPIC: &str = "__krabka_connect_offsets";
17
18#[derive(Clone)]
19pub(crate) struct KafkaClientConfig {
20 pub(crate) bootstrap: String,
21 pub(crate) security: Option<ClientSecurity>,
22 pub(crate) replication_factor: ReplicationFactor,
23 pub(crate) dispatch_queue_capacity: usize,
24 pub(crate) frame_max_bytes: u64,
25}
26
27async fn build_producer(
28 config: &KafkaClientConfig,
29 client_id: String,
30) -> Result<Producer, ConnectError> {
31 let builder = Producer::builder()
32 .bootstrap(config.bootstrap.clone())
33 .client_id(client_id)
34 .dispatch_queue_capacity(config.dispatch_queue_capacity)
35 .frame_max(ByteSize::from_bytes(config.frame_max_bytes))
36 .enable_idempotence(true)
37 .acks(Acks::All);
38 match config.security.clone() {
39 Some(security) => builder.security(security).build().await,
40 None => builder.build().await,
41 }
42 .map_err(|error| ConnectError::Backend(error.to_string()))
43}
44
45pub struct KafkaSink {
47 producer: Producer,
48 client: KafkaClientConfig,
49 topic_prefix: String,
50 ensured_topics: HashSet<String>,
51 pending: Vec<DeliveryHandle>,
52 metrics: WorkerMetrics,
53}
54
55impl KafkaSink {
56 pub async fn start(
62 bootstrap: impl Into<String>,
63 topic_prefix: impl Into<String>,
64 ) -> Result<Self, ConnectError> {
65 let client = KafkaClientConfig {
66 bootstrap: bootstrap.into(),
67 security: None,
68 replication_factor: ReplicationFactor::default(),
69 dispatch_queue_capacity: 64,
70 frame_max_bytes: 100 * 1024 * 1024,
71 };
72 Self::start_with_config(client, topic_prefix.into(), WorkerMetrics::new()).await
73 }
74
75 pub(crate) async fn start_with_config(
76 client: KafkaClientConfig,
77 topic_prefix: String,
78 metrics: WorkerMetrics,
79 ) -> Result<Self, ConnectError> {
80 let producer = build_producer(&client, "krabka-connect-worker-data".to_owned()).await?;
81 Ok(Self {
82 producer,
83 client,
84 topic_prefix,
85 ensured_topics: HashSet::new(),
86 pending: Vec::new(),
87 metrics,
88 })
89 }
90}
91
92#[async_trait]
93impl Sink<Bytes, Bytes> for KafkaSink {
94 async fn put(&mut self, records: Vec<ConnectRecord<Bytes, Bytes>>) -> Result<(), ConnectError> {
95 for record in records {
96 let output = to_producer_record(record, &self.topic_prefix)?;
97 if self.ensured_topics.insert(output.topic.clone()) {
98 krabka_replicator::admin_util::ensure_topic_with_replication_factor(
99 &self.client.bootstrap,
100 &output.topic,
101 1,
102 self.client.security.clone(),
103 self.client.replication_factor,
104 )
105 .await
106 .map_err(ConnectError::Backend)?;
107 }
108 self.pending.push(
109 self.producer
110 .enqueue(output)
111 .await
112 .map_err(|error| ConnectError::Backend(error.to_string()))?,
113 );
114 }
115 Ok(())
116 }
117
118 async fn flush(&mut self) -> Result<(), ConnectError> {
119 let mut first_error = None;
120 for acknowledgement in std::mem::take(&mut self.pending) {
121 match acknowledgement.await {
122 Ok(_) => self.metrics.record_produced(),
123 Err(error) => {
124 first_error.get_or_insert_with(|| error.to_string());
125 }
126 }
127 }
128 if let Err(error) = self.producer.flush().await {
129 first_error.get_or_insert_with(|| error.to_string());
130 }
131 if let Some(error) = first_error {
132 self.metrics.record_error();
133 Err(ConnectError::Backend(error))
134 } else {
135 Ok(())
136 }
137 }
138
139 async fn close(&mut self) -> Result<(), ConnectError> {
140 self.flush().await
141 }
142}
143
144fn to_producer_record(
145 record: ConnectRecord<Bytes, Bytes>,
146 topic_prefix: &str,
147) -> Result<ProducerRecord, ConnectError> {
148 let source_topic = record.topic.ok_or_else(|| {
149 ConnectError::Backend("source record is missing destination topic".to_owned())
150 })?;
151 if matches!(record.partition, Some(partition) if partition < 0) {
152 return Err(ConnectError::Backend(
153 "source record has a negative destination partition".to_owned(),
154 ));
155 }
156 Ok(ProducerRecord {
157 topic: prefixed_topic(topic_prefix, &source_topic),
158 partition: record.partition,
159 key: record.key,
160 value: record.value,
161 headers: record
162 .headers
163 .into_iter()
164 .map(|header| Header {
165 key: header.key,
166 value: header.value,
167 })
168 .collect(),
169 timestamp_ms: record.timestamp,
170 })
171}
172
173fn prefixed_topic(prefix: &str, topic: &str) -> String {
174 let prefix = prefix.trim_end_matches('.');
175 if prefix.is_empty() {
176 topic.to_owned()
177 } else {
178 format!("{prefix}.{}", topic.trim_start_matches('.'))
179 }
180}
181
182pub struct KafkaCheckpointStore {
184 producer: Producer,
185 client: KafkaClientConfig,
186 checkpoint_key: String,
187 metrics: WorkerMetrics,
188}
189
190impl KafkaCheckpointStore {
191 pub async fn start(
197 bootstrap: impl Into<String>,
198 checkpoint_key: impl Into<String>,
199 ) -> Result<Self, ConnectError> {
200 let client = KafkaClientConfig {
201 bootstrap: bootstrap.into(),
202 security: None,
203 replication_factor: ReplicationFactor::default(),
204 dispatch_queue_capacity: 64,
205 frame_max_bytes: 100 * 1024 * 1024,
206 };
207 Self::start_with_config(client, checkpoint_key.into(), WorkerMetrics::new()).await
208 }
209
210 pub(crate) async fn start_with_config(
211 client: KafkaClientConfig,
212 checkpoint_key: String,
213 metrics: WorkerMetrics,
214 ) -> Result<Self, ConnectError> {
215 krabka_replicator::admin_util::ensure_compacted_topic_with_replication_factor(
216 &client.bootstrap,
217 CHECKPOINT_TOPIC,
218 client.security.clone(),
219 client.replication_factor,
220 )
221 .await
222 .map_err(ConnectError::Offset)?;
223 let producer = build_producer(&client, "krabka-connect-worker-checkpoint".to_owned())
224 .await
225 .map_err(|error| ConnectError::Offset(error.to_string()))?;
226 Ok(Self {
227 producer,
228 client,
229 checkpoint_key,
230 metrics,
231 })
232 }
233}
234
235#[async_trait]
236impl CheckpointStore for KafkaCheckpointStore {
237 async fn save(&self, offset: &SourceOffset) -> Result<(), ConnectError> {
238 let value =
239 serde_json::to_vec(offset).map_err(|error| ConnectError::Offset(error.to_string()))?;
240 self.producer
241 .send(ProducerRecord {
242 topic: CHECKPOINT_TOPIC.to_owned(),
243 partition: Some(0),
244 key: Some(Bytes::copy_from_slice(self.checkpoint_key.as_bytes())),
245 value: Some(Bytes::from(value)),
246 headers: Vec::new(),
247 timestamp_ms: None,
248 })
249 .await
250 .map_err(|error| ConnectError::Offset(error.to_string()))?;
251 self.producer
252 .flush()
253 .await
254 .map_err(|error| ConnectError::Offset(error.to_string()))?;
255 self.metrics.record_checkpoint();
256 Ok(())
257 }
258
259 async fn load(&self) -> Result<Option<SourceOffset>, ConnectError> {
260 let value = krabka_replicator::admin_util::read_last_value_for_key(
261 &self.client.bootstrap,
262 CHECKPOINT_TOPIC,
263 self.checkpoint_key.as_bytes(),
264 self.client.security.clone(),
265 )
266 .await
267 .map_err(ConnectError::Offset)?;
268 value
269 .map(|bytes| {
270 serde_json::from_slice(&bytes)
271 .map_err(|error| ConnectError::Offset(error.to_string()))
272 })
273 .transpose()
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use assert2::assert;
280
281 use super::*;
282
283 #[test]
284 fn record_mapping_preserves_kafka_fields_and_applies_prefix() {
285 let source = ConnectRecord::new(
286 Some(Bytes::from_static(b"key")),
287 Some(Bytes::from_static(b"value")),
288 )
289 .with_topic("public.orders")
290 .with_partition(2)
291 .with_timestamp(42)
292 .with_header("trace", Some(Bytes::from_static(b"abc")));
293 let output = to_producer_record(source, "db.").expect("record maps");
294 assert!(
295 output
296 == ProducerRecord {
297 topic: "db.public.orders".to_owned(),
298 partition: Some(2),
299 key: Some(Bytes::from_static(b"key")),
300 value: Some(Bytes::from_static(b"value")),
301 headers: vec![Header {
302 key: "trace".to_owned(),
303 value: Some(Bytes::from_static(b"abc")),
304 }],
305 timestamp_ms: Some(42),
306 }
307 );
308 }
309
310 #[test]
311 fn record_mapping_preserves_tombstone_and_rejects_missing_topic() {
312 let tombstone =
313 ConnectRecord::new(Some(Bytes::from_static(b"key")), None).with_topic("orders");
314 let output = to_producer_record(tombstone, "").expect("tombstone maps");
315 assert!(output.value.is_none());
316 assert!(output.topic == "orders");
317 assert!(to_producer_record(ConnectRecord::new(None, None), "db").is_err());
318 }
319}