1use std::collections::HashSet;
5
6use async_trait::async_trait;
7use bytes::Bytes;
8use crabka_client_producer::{
9 Acks, Header, Producer, ProducerError, ProducerRecord, RecordMetadata,
10};
11use crabka_connect::{ConnectError, ConnectRecord, Sink};
12use tokio::sync::oneshot::Receiver;
13use tracing::warn;
14
15use crate::config::{NamingPolicy, PolicyConfig};
16use crate::mm2::OffsetSync;
17use crate::naming::{PROVENANCE_HEADER, Renamer};
18use crate::record::ReplicatedRecord;
19use crate::residency::ResidencyGate;
20
21type PendingProduce = (
25 Receiver<Result<RecordMetadata, ProducerError>>,
26 String,
27 i32,
28 i64,
29);
30
31pub struct SinkParams {
33 pub target_bootstrap: String,
35 pub source_alias: String,
37 pub naming: NamingPolicy,
39 pub target_zones: Vec<String>,
41 pub policies: Vec<PolicyConfig>,
43 pub security: Option<crabka_client_core::security::ClientSecurity>,
45}
46
47pub struct TargetSink {
54 producer: Producer,
55 renamer: Renamer,
56 gate: ResidencyGate,
57 target_zones: Vec<String>,
58 offset_syncs_topic: String,
59 source_alias: String,
60 target_bootstrap: String,
62 security: Option<crabka_client_core::security::ClientSecurity>,
64 created_topics: HashSet<String>,
66 pending: Vec<PendingProduce>,
68 offset_syncs: Vec<OffsetSync>,
70}
71
72impl TargetSink {
73 pub async fn start(params: SinkParams) -> Result<Self, ConnectError> {
81 let offset_syncs_topic = OffsetSync::topic_name(¶ms.source_alias);
82
83 crate::admin_util::ensure_topic(
85 ¶ms.target_bootstrap,
86 &offset_syncs_topic,
87 1,
88 params.security.clone(),
89 )
90 .await
91 .map_err(ConnectError::Backend)?;
92
93 let producer = build_producer(¶ms.target_bootstrap, params.security.clone())
95 .await
96 .map_err(|e| ConnectError::Backend(e.to_string()))?;
97
98 let renamer = Renamer::new(params.naming, ¶ms.source_alias);
99
100 let gate = ResidencyGate::compile(¶ms.policies)
101 .map_err(|e| ConnectError::Backend(e.to_string()))?;
102
103 Ok(Self {
104 producer,
105 renamer,
106 gate,
107 target_zones: params.target_zones,
108 offset_syncs_topic,
109 source_alias: params.source_alias,
110 target_bootstrap: params.target_bootstrap,
111 security: params.security,
112 created_topics: HashSet::new(),
113 pending: Vec::new(),
114 offset_syncs: Vec::new(),
115 })
116 }
117
118 pub fn drain_offset_syncs(&mut self) -> Vec<OffsetSync> {
121 std::mem::take(&mut self.offset_syncs)
122 }
123}
124
125async fn build_producer(
127 bootstrap: &str,
128 security: Option<crabka_client_core::security::ClientSecurity>,
129) -> Result<Producer, crabka_client_producer::ProducerError> {
130 let builder = Producer::builder()
131 .bootstrap(bootstrap)
132 .enable_idempotence(false)
133 .acks(Acks::All);
134 match security {
135 Some(s) => builder.security(s).build().await,
136 None => builder.build().await,
137 }
138}
139
140#[async_trait]
141impl Sink<(), ReplicatedRecord> for TargetSink {
142 async fn put(
151 &mut self,
152 records: Vec<ConnectRecord<(), ReplicatedRecord>>,
153 ) -> Result<(), ConnectError> {
154 for cr in records {
155 let Some(r) = cr.value else { continue };
156
157 if self.renamer.policy() == NamingPolicy::Identity {
159 let own_alias = self.source_alias.as_bytes();
160 let is_loop = r
161 .headers
162 .iter()
163 .any(|(k, v)| k == PROVENANCE_HEADER && v.as_deref() == Some(own_alias));
164 if is_loop {
165 continue;
166 }
167 }
168
169 if !self.gate.permits(&r.topic, &self.target_zones) {
171 warn!(
172 topic = %r.topic,
173 target_zones = ?self.target_zones,
174 "residency gate blocked record; dropping"
175 );
176 continue;
177 }
178
179 let target_topic = self.renamer.target_name(&r.topic);
181
182 if !self.created_topics.contains(&target_topic) {
184 crate::admin_util::ensure_topic(
185 &self.target_bootstrap,
186 &target_topic,
187 1,
188 self.security.clone(),
189 )
190 .await
191 .map_err(ConnectError::Backend)?;
192 self.created_topics.insert(target_topic.clone());
193 }
194
195 let mut headers: Vec<Header> = r
197 .headers
198 .iter()
199 .map(|(k, v)| Header {
200 key: k.clone(),
201 value: v.clone(),
202 })
203 .collect();
204 headers.push(Header {
205 key: PROVENANCE_HEADER.to_owned(),
206 value: Some(Bytes::from(self.source_alias.clone())),
207 });
208
209 let rx = self
211 .producer
212 .send(ProducerRecord {
213 topic: target_topic,
214 partition: None,
215 key: r.key.clone(),
216 value: r.value.clone(),
217 headers,
218 timestamp_ms: Some(r.timestamp),
219 })
220 .await;
221
222 self.pending.push((rx, r.topic, r.partition, r.offset));
223 }
224
225 Ok(())
226 }
227
228 async fn flush(&mut self) -> Result<(), ConnectError> {
231 let pending = std::mem::take(&mut self.pending);
232
233 for (rx, topic, partition, upstream) in pending {
234 let meta = rx
236 .await
237 .map_err(|_| ConnectError::Backend("producer dropped sender".into()))?
238 .map_err(|e| ConnectError::Backend(e.to_string()))?;
239
240 let offset_sync = OffsetSync {
244 topic,
245 partition,
246 upstream,
247 downstream: meta.offset,
248 };
249
250 let sync_rx = self
252 .producer
253 .send(ProducerRecord {
254 topic: self.offset_syncs_topic.clone(),
255 partition: None,
256 key: Some(Bytes::from(offset_sync.key_bytes())),
257 value: Some(Bytes::from(offset_sync.value_bytes())),
258 headers: Vec::new(),
259 timestamp_ms: None,
260 })
261 .await;
262
263 sync_rx
264 .await
265 .map_err(|_| ConnectError::Backend("producer dropped offset-sync sender".into()))?
266 .map_err(|e| ConnectError::Backend(e.to_string()))?;
267
268 self.offset_syncs.push(offset_sync);
269 }
270
271 self.producer
272 .flush()
273 .await
274 .map_err(|e| ConnectError::Backend(e.to_string()))?;
275
276 Ok(())
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use assert2::assert;
283
284 use super::*;
285 use crabka_connect::Sink;
286
287 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
288 async fn produces_renamed_and_records_offset_sync_but_blocks_denied() {
289 let dir = tempfile::TempDir::new().unwrap();
290 let broker = crabka_broker::Broker::start(crabka_broker::BrokerConfig::for_tests(
291 dir.path().to_path_buf(),
292 ))
293 .await
294 .unwrap();
295 let target = broker.listen_addr().to_string();
296
297 let mut sink = TargetSink::start(SinkParams {
298 target_bootstrap: target.clone(),
299 source_alias: "us-east".into(),
300 naming: crate::config::NamingPolicy::Default,
301 target_zones: vec!["us".into()],
302 policies: vec![crate::config::PolicyConfig {
303 name: "p".into(),
304 topics: vec!["secret".into()],
305 residency: Some(crate::config::Residency {
306 allow_zones: vec!["gdpr".into()],
307 deny_zones: vec![],
308 }),
309 }],
310 security: None,
311 })
312 .await
313 .unwrap();
314
315 let allowed = ConnectRecord::new(
316 None,
317 Some(ReplicatedRecord {
318 topic: "orders".into(),
319 partition: 0,
320 offset: 5,
321 timestamp: 1,
322 key: Some("k".into()),
323 value: Some("v".into()),
324 headers: vec![],
325 }),
326 );
327 let denied = ConnectRecord::new(
328 None,
329 Some(ReplicatedRecord {
330 topic: "secret".into(),
331 partition: 0,
332 offset: 9,
333 timestamp: 1,
334 key: None,
335 value: Some("x".into()),
336 headers: vec![],
337 }),
338 );
339
340 sink.put(vec![allowed, denied]).await.unwrap();
341 sink.flush().await.unwrap();
342
343 assert!(crate::test_util::topic_record_count(&target, "us-east.orders").await == 1);
344 assert!(crate::test_util::topic_record_count(&target, "us-east.secret").await == 0);
345
346 let syncs = sink.drain_offset_syncs();
347 assert!(
348 syncs
349 .iter()
350 .any(|s| s.topic == "orders" && s.partition == 0 && s.upstream == 5)
351 );
352 }
353
354 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
355 async fn identity_naming_loop_guard_skips_only_own_provenance() {
356 let dir = tempfile::TempDir::new().unwrap();
357 let broker = crabka_broker::Broker::start(crabka_broker::BrokerConfig::for_tests(
358 dir.path().to_path_buf(),
359 ))
360 .await
361 .unwrap();
362 let target = broker.listen_addr().to_string();
363
364 let mut sink = TargetSink::start(SinkParams {
368 target_bootstrap: target.clone(),
369 source_alias: "us-east".into(),
370 naming: crate::config::NamingPolicy::Identity,
371 target_zones: vec!["us".into()],
372 policies: vec![],
373 security: None,
374 })
375 .await
376 .unwrap();
377
378 let a = ConnectRecord::new(
380 None,
381 Some(ReplicatedRecord {
382 topic: "orders".into(),
383 partition: 0,
384 offset: 1,
385 timestamp: 1,
386 key: Some("a".into()),
387 value: Some("v".into()),
388 headers: vec![(PROVENANCE_HEADER.into(), Some("us-east".into()))],
389 }),
390 );
391 let b = ConnectRecord::new(
393 None,
394 Some(ReplicatedRecord {
395 topic: "orders".into(),
396 partition: 0,
397 offset: 2,
398 timestamp: 1,
399 key: Some("b".into()),
400 value: Some("v".into()),
401 headers: vec![(PROVENANCE_HEADER.into(), Some("eu-west".into()))],
402 }),
403 );
404 let c = ConnectRecord::new(
406 None,
407 Some(ReplicatedRecord {
408 topic: "orders".into(),
409 partition: 0,
410 offset: 3,
411 timestamp: 1,
412 key: Some("c".into()),
413 value: Some("v".into()),
414 headers: vec![("other".into(), Some("us-east".into()))],
415 }),
416 );
417
418 sink.put(vec![a, b, c]).await.unwrap();
419 sink.flush().await.unwrap();
420
421 let produced = crate::admin_util::read_all(&target, "orders", None)
427 .await
428 .unwrap();
429 let mut keys: Vec<Vec<u8>> = produced
430 .into_iter()
431 .filter_map(|(k, _)| k.map(|b| b.to_vec()))
432 .collect();
433 keys.sort();
434 assert!(keys == vec![b"b".to_vec(), b"c".to_vec()]);
435 }
436}