1use std::{
5 collections::{BTreeMap, HashSet, VecDeque},
6 sync::Arc,
7};
8
9use async_trait::async_trait;
10use bytes::Bytes;
11use krabka_client_producer::{
12 Acks, DeliveryHandle, Header, OwnedTransaction, Producer, ProducerRecord,
13};
14use krabka_connect::{ConnectError, ConnectRecord, OffsetValue, Sink, SourceOffset};
15use krabka_units::prelude::TimeExt as _;
16use tracing::warn;
17
18use crate::{
19 checkpoint_store::STATE_TOPIC,
20 config::{ClientResourcePolicy, Delivery, NamingPolicy, PolicyConfig, ReplicatorRuntimePolicy},
21 ids::{DownstreamOffset, PartitionIndex, UpstreamOffset},
22 mm2::OffsetSync,
23 naming::{PROVENANCE_HEADER, Renamer},
24 record::ReplicatedRecord,
25 residency::ResidencyGate,
26};
27
28const OFFSET_SYNC_WINDOW: usize = 64;
29
30#[derive(Default)]
32struct OffsetSyncObservations {
33 capacity: usize,
34 records: VecDeque<OffsetSync>,
35}
36
37impl OffsetSyncObservations {
38 fn set_capacity(&mut self, capacity: usize) {
39 self.capacity = capacity;
40 if capacity == 0 {
41 self.records = VecDeque::new();
42 } else {
43 let discarded = self.records.len().saturating_sub(capacity);
44 self.records.drain(..discarded);
45 self.records.shrink_to(capacity);
46 }
47 }
48
49 fn record(&mut self, completed: Vec<OffsetSync>) {
50 if self.capacity == 0 {
51 return;
52 }
53 let incoming = completed.len().min(self.capacity);
54 let discarded = self.records.len().saturating_sub(self.capacity - incoming);
55 self.records.drain(..discarded);
56 let skipped = completed.len().saturating_sub(self.capacity);
57 self.records.extend(completed.into_iter().skip(skipped));
58 }
59
60 fn drain(&mut self) -> Vec<OffsetSync> {
61 self.records.drain(..).collect()
62 }
63}
64
65struct PendingProduce {
69 rx: DeliveryHandle,
71 topic: String,
73 partition: PartitionIndex,
75 upstream: UpstreamOffset,
77}
78
79pub struct SinkParams {
81 pub target_bootstrap: String,
83 pub flow_name: String,
85 pub source_alias: String,
87 pub delivery: Delivery,
89 pub naming: NamingPolicy,
91 pub target_zones: Vec<String>,
93 pub policies: Vec<PolicyConfig>,
95 pub security: Option<krabka_client_core::security::ClientSecurity>,
97 pub source_partition_counts: BTreeMap<String, i32>,
99}
100
101pub struct TargetSink {
109 producer: Arc<Producer>,
110 delivery: Delivery,
111 flow_name: String,
112 renamer: Renamer,
113 gate: ResidencyGate,
114 target_zones: Vec<String>,
115 offset_syncs_topic: String,
116 source_alias: String,
117 target_bootstrap: String,
119 security: Option<krabka_client_core::security::ClientSecurity>,
121 client_resource_policy: ClientResourcePolicy,
122 runtime_policy: ReplicatorRuntimePolicy,
123 source_partition_counts: BTreeMap<String, i32>,
124 created_topics: HashSet<String>,
127 pending: Vec<PendingProduce>,
129 transaction: Option<OwnedTransaction>,
131 committed_checkpoint: SourceOffset,
133 pending_checkpoint: Option<SourceOffset>,
135 offset_syncs: OffsetSyncObservations,
137}
138
139impl TargetSink {
140 #[tracing::instrument(
148 level = "info",
149 skip_all,
150 fields(target = %params.target_bootstrap, source_alias = %params.source_alias),
151 err,
152 )]
153 pub async fn start(params: SinkParams) -> Result<Self, ConnectError> {
154 Self::start_with_policy(params, ClientResourcePolicy::default()).await
155 }
156
157 pub async fn start_with_policy(
164 params: SinkParams,
165 client_resource_policy: ClientResourcePolicy,
166 ) -> Result<Self, ConnectError> {
167 Self::start_with_runtime_policy(
168 params,
169 client_resource_policy,
170 ReplicatorRuntimePolicy::default(),
171 )
172 .await
173 }
174
175 pub(crate) async fn start_with_runtime_policy(
176 params: SinkParams,
177 client_resource_policy: ClientResourcePolicy,
178 runtime_policy: ReplicatorRuntimePolicy,
179 ) -> Result<Self, ConnectError> {
180 let offset_syncs_topic = OffsetSync::topic_name(¶ms.source_alias);
181
182 crate::admin_util::ensure_topic_with_runtime_policy(
184 ¶ms.target_bootstrap,
185 &offset_syncs_topic,
186 1,
187 params.security.clone(),
188 client_resource_policy,
189 &runtime_policy,
190 runtime_policy.internal_topic_replication_factor,
191 )
192 .await
193 .map_err(ConnectError::Backend)?;
194
195 if params.delivery == Delivery::ExactlyOnce {
196 crate::admin_util::ensure_compacted_topic_with_runtime_policy(
197 ¶ms.target_bootstrap,
198 STATE_TOPIC,
199 params.security.clone(),
200 client_resource_policy,
201 &runtime_policy,
202 )
203 .await
204 .map_err(ConnectError::Offset)?;
205 }
206
207 let producer = build_producer(
209 ¶ms.target_bootstrap,
210 ¶ms.flow_name,
211 params.delivery,
212 params.security.clone(),
213 client_resource_policy,
214 &runtime_policy,
215 )
216 .await
217 .map_err(|e| ConnectError::Backend(e.to_string()))?;
218
219 let committed_checkpoint = if params.delivery == Delivery::ExactlyOnce {
220 let bytes = crate::admin_util::read_last_value_for_key_with_runtime_policy(
221 ¶ms.target_bootstrap,
222 STATE_TOPIC,
223 params.flow_name.as_bytes(),
224 params.security.clone(),
225 client_resource_policy,
226 &runtime_policy,
227 )
228 .await
229 .map_err(ConnectError::Offset)?;
230 bytes.map_or_else(
231 || Ok(SourceOffset::default()),
232 |bytes| {
233 serde_json::from_slice(&bytes)
234 .map_err(|error| ConnectError::Offset(error.to_string()))
235 },
236 )?
237 } else {
238 SourceOffset::default()
239 };
240
241 let renamer = Renamer::new(params.naming, ¶ms.source_alias);
242
243 let gate = ResidencyGate::compile(¶ms.policies)
244 .map_err(|e| ConnectError::Backend(e.to_string()))?;
245
246 Ok(Self {
247 producer,
248 delivery: params.delivery,
249 flow_name: params.flow_name,
250 renamer,
251 gate,
252 target_zones: params.target_zones,
253 offset_syncs_topic,
254 source_alias: params.source_alias,
255 target_bootstrap: params.target_bootstrap,
256 security: params.security,
257 client_resource_policy,
258 runtime_policy,
259 source_partition_counts: params.source_partition_counts,
260 created_topics: HashSet::new(),
261 pending: Vec::new(),
262 transaction: None,
263 committed_checkpoint,
264 pending_checkpoint: None,
265 offset_syncs: OffsetSyncObservations::default(),
266 })
267 }
268
269 pub fn set_offset_sync_observation_capacity(&mut self, capacity: usize) {
275 self.offset_syncs.set_capacity(capacity);
276 }
277
278 pub fn drain_offset_syncs(&mut self) -> Vec<OffsetSync> {
284 self.offset_syncs.drain()
285 }
286
287 fn stage_source_offset(&mut self, record: &ReplicatedRecord) -> Result<(), ConnectError> {
288 let Some(checkpoint) = self.pending_checkpoint.as_mut() else {
289 return Ok(());
290 };
291 let next = record.offset.0.checked_add(1).ok_or_else(|| {
292 ConnectError::Offset(format!(
293 "source offset overflow for {}-{}",
294 record.topic, record.partition
295 ))
296 })?;
297 checkpoint.position.0.insert(
298 format!("{}-{}", record.topic, record.partition),
299 OffsetValue::Long(next),
300 );
301 Ok(())
302 }
303
304 async fn flush_pending(&mut self) -> Result<Vec<OffsetSync>, ConnectError> {
305 let pending = std::mem::take(&mut self.pending);
306 let mut completed = Vec::with_capacity(pending.len());
307
308 let mut pending = pending.into_iter().peekable();
311 while pending.peek().is_some() {
312 let mut sync_deliveries = Vec::with_capacity(OFFSET_SYNC_WINDOW);
313 for PendingProduce {
314 rx,
315 topic,
316 partition,
317 upstream,
318 } in pending.by_ref().take(OFFSET_SYNC_WINDOW)
319 {
320 let meta = rx
321 .await
322 .map_err(|error| ConnectError::Backend(error.to_string()))?;
323 let offset_sync = OffsetSync {
324 topic,
325 partition,
326 upstream,
327 downstream: DownstreamOffset(meta.offset),
328 };
329 let delivery = self
330 .producer
331 .enqueue(ProducerRecord {
332 topic: self.offset_syncs_topic.clone(),
333 partition: None,
334 key: Some(Bytes::from(offset_sync.key_bytes())),
335 value: Some(Bytes::from(offset_sync.value_bytes())),
336 headers: Vec::new(),
337 timestamp_ms: None,
338 })
339 .await
340 .map_err(|error| ConnectError::Backend(error.to_string()))?;
341 sync_deliveries.push((delivery, offset_sync));
342 }
343 for (delivery, offset_sync) in sync_deliveries {
346 delivery
347 .await
348 .map_err(|error| ConnectError::Backend(error.to_string()))?;
349 completed.push(offset_sync);
350 }
351 }
352
353 self.producer
354 .flush()
355 .await
356 .map_err(|error| ConnectError::Backend(error.to_string()))?;
357 Ok(completed)
358 }
359
360 async fn write_transaction_checkpoint(&self) -> Result<(), ConnectError> {
361 let checkpoint = self.pending_checkpoint.as_ref().ok_or_else(|| {
362 ConnectError::Transaction("exactly-once transaction was not begun".into())
363 })?;
364 let bytes = serde_json::to_vec(checkpoint)
365 .map_err(|error| ConnectError::Offset(error.to_string()))?;
366 self.producer
367 .send(ProducerRecord {
368 topic: STATE_TOPIC.to_owned(),
369 partition: None,
370 key: Some(Bytes::copy_from_slice(self.flow_name.as_bytes())),
371 value: Some(Bytes::from(bytes)),
372 headers: Vec::new(),
373 timestamp_ms: None,
374 })
375 .await
376 .map_err(|error| ConnectError::Offset(error.to_string()))?;
377 Ok(())
378 }
379}
380
381async fn build_producer(
383 bootstrap: &str,
384 flow_name: &str,
385 delivery: Delivery,
386 security: Option<krabka_client_core::security::ClientSecurity>,
387 client_resource_policy: ClientResourcePolicy,
388 runtime_policy: &ReplicatorRuntimePolicy,
389) -> Result<Arc<Producer>, krabka_client_producer::ProducerError> {
390 let builder = Producer::builder()
391 .bootstrap(bootstrap)
392 .dispatch_queue_capacity(client_resource_policy.dispatch_queue_capacity.get())
393 .frame_max(client_resource_policy.frame_max.size())
394 .dns_timeout(runtime_policy.client_dns_timeout)
395 .request_timeout(runtime_policy.client_request_timeout.to_std())
396 .acks(Acks::All);
397 let producer = match (delivery, security) {
398 (Delivery::AtLeastOnce, Some(security)) => {
399 builder
400 .enable_idempotence(false)
401 .security(security)
402 .build()
403 .await?
404 }
405 (Delivery::AtLeastOnce, None) => builder.enable_idempotence(false).build().await?,
406 (Delivery::ExactlyOnce, Some(security)) => {
407 builder
408 .enable_idempotence(true)
409 .transactional_id(format!("krabka-replicator-{flow_name}"))
410 .security(security)
411 .build()
412 .await?
413 }
414 (Delivery::ExactlyOnce, None) => {
415 builder
416 .enable_idempotence(true)
417 .transactional_id(format!("krabka-replicator-{flow_name}"))
418 .build()
419 .await?
420 }
421 };
422 let producer = Arc::new(producer);
423 if delivery == Delivery::ExactlyOnce {
424 producer.init_transactions().await?;
425 }
426 Ok(producer)
427}
428
429#[async_trait]
430impl Sink<(), ReplicatedRecord> for TargetSink {
431 #[tracing::instrument(
440 level = "debug",
441 skip_all,
442 fields(records = records.len(), accepted = tracing::field::Empty),
443 err,
444 )]
445 async fn put(
446 &mut self,
447 records: Vec<ConnectRecord<(), ReplicatedRecord>>,
448 ) -> Result<(), ConnectError> {
449 if self.delivery == Delivery::ExactlyOnce && self.transaction.is_none() {
450 return Err(ConnectError::Transaction(
451 "exactly-once put must follow begin".into(),
452 ));
453 }
454 let mut accepted = 0usize;
455 for cr in records {
456 let Some(r) = cr.value else { continue };
457 self.stage_source_offset(&r)?;
458
459 if self.renamer.policy() == NamingPolicy::Identity {
461 let own_alias = self.source_alias.as_bytes();
462 let is_loop = r
463 .headers
464 .iter()
465 .any(|(k, v)| k == PROVENANCE_HEADER && v.as_deref() == Some(own_alias));
466 if is_loop {
467 continue;
468 }
469 }
470
471 if !self.gate.permits(&r.topic, &self.target_zones) {
473 warn!(
474 topic = %r.topic,
475 target_zones = ?self.target_zones,
476 "residency gate blocked record; dropping"
477 );
478 continue;
479 }
480
481 let target_topic = self.renamer.target_name(&r.topic);
483
484 if !self.created_topics.contains(&target_topic) {
486 let partitions = self
487 .source_partition_counts
488 .get(&r.topic)
489 .copied()
490 .ok_or_else(|| {
491 ConnectError::Backend(format!(
492 "missing source partition count for {}",
493 r.topic
494 ))
495 })?;
496 crate::admin_util::ensure_topic_with_runtime_policy(
497 &self.target_bootstrap,
498 &target_topic,
499 partitions,
500 self.security.clone(),
501 self.client_resource_policy,
502 &self.runtime_policy,
503 self.runtime_policy.data_topic_replication_factor,
504 )
505 .await
506 .map_err(ConnectError::Backend)?;
507 self.created_topics.insert(target_topic.clone());
508 }
509
510 let mut headers: Vec<Header> = r
512 .headers
513 .iter()
514 .map(|(k, v)| Header {
515 key: k.clone(),
516 value: v.clone(),
517 })
518 .collect();
519 headers.push(Header {
520 key: PROVENANCE_HEADER.to_owned(),
521 value: Some(Bytes::from(self.source_alias.clone())),
522 });
523
524 let rx = self
526 .producer
527 .enqueue(ProducerRecord {
528 topic: target_topic,
529 partition: Some(r.partition.0),
530 key: r.key.clone(),
531 value: r.value.clone(),
532 headers,
533 timestamp_ms: Some(r.timestamp.into()),
534 })
535 .await
536 .map_err(|error| ConnectError::Backend(error.to_string()))?;
537
538 self.pending.push(PendingProduce {
539 rx,
540 topic: r.topic,
541 partition: r.partition,
542 upstream: r.offset.into(),
544 });
545 accepted += 1;
546 }
547
548 tracing::Span::current().record("accepted", accepted);
549 Ok(())
550 }
551
552 #[tracing::instrument(
555 level = "debug",
556 skip_all,
557 fields(pending = self.pending.len()),
558 err,
559 )]
560 async fn flush(&mut self) -> Result<(), ConnectError> {
561 if self.delivery == Delivery::ExactlyOnce {
562 return Err(ConnectError::Transaction(
563 "exactly-once sink must use commit".into(),
564 ));
565 }
566 let completed = self.flush_pending().await?;
567 self.offset_syncs.record(completed);
568 Ok(())
569 }
570
571 fn supports_transactions(&self) -> bool {
572 self.delivery == Delivery::ExactlyOnce
573 }
574
575 async fn begin(&mut self) -> Result<(), ConnectError> {
576 if self.delivery != Delivery::ExactlyOnce {
577 return Ok(());
578 }
579 if self.transaction.is_some() {
580 return Err(ConnectError::Transaction(
581 "exactly-once transaction is already open".into(),
582 ));
583 }
584 self.transaction = Some(
585 Arc::clone(&self.producer)
586 .begin_transaction_owned()
587 .await
588 .map_err(|error| ConnectError::Transaction(error.to_string()))?,
589 );
590 self.pending_checkpoint = Some(self.committed_checkpoint.clone());
591 Ok(())
592 }
593
594 async fn commit(&mut self) -> Result<(), ConnectError> {
595 if self.delivery != Delivery::ExactlyOnce {
596 return self.flush().await;
597 }
598 if self.transaction.is_none() {
599 return Err(ConnectError::Transaction(
600 "exactly-once commit must follow begin".into(),
601 ));
602 }
603
604 let completed = self.flush_pending().await?;
605 self.write_transaction_checkpoint().await?;
606 let transaction = self
607 .transaction
608 .take()
609 .expect("transaction checked immediately above");
610 match transaction.commit().await {
611 Ok(()) => {
612 self.offset_syncs.record(completed);
613 self.committed_checkpoint = self
614 .pending_checkpoint
615 .take()
616 .expect("begin staged a checkpoint");
617 Ok(())
618 }
619 Err(error) => {
620 self.transaction = Some(error.transaction);
621 Err(ConnectError::Transaction(error.source.to_string()))
622 }
623 }
624 }
625
626 async fn abort(&mut self) -> Result<(), ConnectError> {
627 if self.delivery != Delivery::ExactlyOnce {
628 return Ok(());
629 }
630 self.pending.clear();
631 self.pending_checkpoint = None;
632 let Some(transaction) = self.transaction.take() else {
633 return Ok(());
634 };
635 match transaction.abort().await {
636 Ok(()) => Ok(()),
637 Err(error) => {
638 self.transaction = Some(error.transaction);
639 Err(ConnectError::Transaction(error.source.to_string()))
640 }
641 }
642 }
643
644 async fn close(&mut self) -> Result<(), ConnectError> {
645 self.abort().await
646 }
647}
648
649#[cfg(test)]
650mod tests {
651
652 use krabka_connect::{CheckpointStore, Sink};
653
654 use super::*;
655 use crate::ids::{Offset, Timestamp};
656
657 fn observation(offset: i64) -> OffsetSync {
658 OffsetSync {
659 topic: "orders".into(),
660 partition: PartitionIndex(0),
661 upstream: UpstreamOffset(offset),
662 downstream: DownstreamOffset(offset),
663 }
664 }
665
666 #[test]
667 fn observations_are_disabled_by_default_and_bounded_when_enabled() {
668 for capacity in [0, 3, 64] {
669 let mut observations = OffsetSyncObservations::default();
670 observations.set_capacity(capacity);
671 for batch in 0..1_000 {
672 observations.record((batch * 500..(batch + 1) * 500).map(observation).collect());
673 assert2::assert!(observations.records.len() <= capacity);
674 if capacity == 0 {
675 assert2::assert!(observations.records.capacity() == 0);
676 }
677 }
678 let expected: Vec<_> = (500_000 - i64::try_from(capacity).unwrap()..500_000)
679 .map(observation)
680 .collect();
681 assert2::assert!(observations.drain() == expected);
682 assert2::assert!(observations.drain().is_empty());
683 }
684 }
685
686 #[test]
687 fn changing_observation_capacity_keeps_the_newest_records_and_can_disable_it() {
688 let mut observations = OffsetSyncObservations::default();
689 observations.set_capacity(5);
690 observations.record((0..4).map(observation).collect());
691 observations.record((4..7).map(observation).collect());
692 observations.set_capacity(3);
693 assert2::assert!(observations.drain() == (4..7).map(observation).collect::<Vec<_>>());
694 observations.record(vec![observation(7)]);
695 observations.set_capacity(0);
696 assert2::assert!(observations.drain().is_empty());
697 assert2::assert!(observations.records.capacity() == 0);
698 }
699
700 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
701 async fn repeated_flushes_and_commits_publish_every_sync_without_retaining_observations() {
702 let dir = tempfile::TempDir::new().unwrap();
703 let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
704 dir.path().to_path_buf(),
705 ))
706 .await
707 .unwrap();
708 let target = broker.listen_addr().to_string();
709
710 for (delivery, alias) in [
711 (Delivery::AtLeastOnce, "alo"),
712 (Delivery::ExactlyOnce, "eos"),
713 ] {
714 let mut sink = TargetSink::start(SinkParams {
715 target_bootstrap: target.clone(),
716 flow_name: alias.into(),
717 source_alias: alias.into(),
718 delivery,
719 naming: NamingPolicy::Default,
720 target_zones: Vec::new(),
721 policies: Vec::new(),
722 security: None,
723 source_partition_counts: [("orders".to_owned(), 1)].into(),
724 })
725 .await
726 .unwrap();
727 for batch in 0..20 {
728 if delivery == Delivery::ExactlyOnce {
729 sink.begin().await.unwrap();
730 }
731 let records = (batch * 8..(batch + 1) * 8)
732 .map(|offset| {
733 ConnectRecord::new(
734 None,
735 Some(ReplicatedRecord {
736 topic: "orders".into(),
737 partition: PartitionIndex(0),
738 offset: Offset(offset),
739 timestamp: Timestamp(1),
740 key: None,
741 value: Some("v".into()),
742 headers: Vec::new(),
743 }),
744 )
745 })
746 .collect();
747 sink.put(records).await.unwrap();
748 if delivery == Delivery::ExactlyOnce {
749 sink.commit().await.unwrap();
750 } else {
751 sink.flush().await.unwrap();
752 }
753 assert2::assert!(sink.offset_syncs.records.is_empty());
754 assert2::assert!(sink.offset_syncs.records.capacity() == 0);
755 }
756 let durable =
757 crate::admin_util::read_all(&target, &OffsetSync::topic_name(alias), None)
758 .await
759 .unwrap();
760 let expected: Vec<_> = (0..160)
761 .map(|offset| {
762 let mut sync = observation(offset);
763 if delivery == Delivery::ExactlyOnce {
764 sync.downstream = DownstreamOffset(offset + offset / 8);
767 }
768 (
769 Some(Bytes::from(sync.key_bytes())),
770 Some(Bytes::from(sync.value_bytes())),
771 )
772 })
773 .collect();
774 assert2::assert!(durable == expected);
775 assert2::assert!(
776 crate::test_util::topic_record_count(&target, &format!("{alias}.orders")).await
777 == 160
778 );
779 if delivery == Delivery::ExactlyOnce {
780 assert2::assert!(
781 sink.committed_checkpoint.position.get("orders-0")
782 == Some(&OffsetValue::Long(160))
783 );
784 }
785 }
786 }
787
788 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
789 async fn produces_renamed_and_records_offset_sync_but_blocks_denied() {
790 let dir = tempfile::TempDir::new().unwrap();
791 let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
792 dir.path().to_path_buf(),
793 ))
794 .await
795 .unwrap();
796 let target = broker.listen_addr().to_string();
797
798 let mut sink = TargetSink::start(SinkParams {
799 target_bootstrap: target.clone(),
800 flow_name: "us-east__eu-west".into(),
801 source_alias: "us-east".into(),
802 delivery: Delivery::AtLeastOnce,
803 naming: crate::config::NamingPolicy::Default,
804 target_zones: vec!["us".into()],
805 policies: vec![crate::config::PolicyConfig {
806 name: "p".into(),
807 topics: vec!["secret".into()],
808 residency: Some(crate::config::Residency {
809 allow_zones: vec!["gdpr".into()],
810 deny_zones: vec![],
811 }),
812 }],
813 security: None,
814 source_partition_counts: [("orders".to_string(), 3)].into(),
815 })
816 .await
817 .unwrap();
818
819 sink.set_offset_sync_observation_capacity(130);
820 let allowed = ConnectRecord::new(
821 None,
822 Some(ReplicatedRecord {
823 topic: "orders".into(),
824 partition: PartitionIndex(2),
825 offset: Offset(5),
826 timestamp: Timestamp(1),
827 key: Some("k".into()),
828 value: Some("v".into()),
829 headers: vec![],
830 }),
831 );
832 let denied = ConnectRecord::new(
833 None,
834 Some(ReplicatedRecord {
835 topic: "secret".into(),
836 partition: PartitionIndex(0),
837 offset: Offset(9),
838 timestamp: Timestamp(1),
839 key: None,
840 value: Some("x".into()),
841 headers: vec![],
842 }),
843 );
844
845 let allowed = (0..130).map(|offset| {
846 let mut record = allowed.clone();
847 record.value.as_mut().unwrap().offset = Offset(offset);
848 record
849 });
850 sink.put(allowed.chain([denied]).collect()).await.unwrap();
851 sink.flush().await.unwrap();
852
853 assert2::assert!(
854 crate::test_util::topic_record_count(&target, "us-east.orders").await == 130
855 );
856 assert2::assert!(
857 crate::test_util::topic_record_count(&target, "us-east.secret").await == 0
858 );
859
860 let syncs = sink.drain_offset_syncs();
861 let expected: Vec<_> = (0..130)
862 .map(|offset| OffsetSync {
863 topic: "orders".into(),
864 partition: PartitionIndex(2),
865 upstream: UpstreamOffset(offset),
866 downstream: DownstreamOffset(offset),
867 })
868 .collect();
869 assert2::assert!(syncs == expected);
870 let persisted =
871 crate::admin_util::read_all(&target, &OffsetSync::topic_name("us-east"), None)
872 .await
873 .unwrap();
874 let expected: Vec<_> = expected
875 .iter()
876 .map(|sync| {
877 (
878 Some(Bytes::from(sync.key_bytes())),
879 Some(Bytes::from(sync.value_bytes())),
880 )
881 })
882 .collect();
883 assert2::assert!(persisted == expected);
884 }
885
886 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
887 async fn identity_naming_loop_guard_skips_only_own_provenance() {
888 let dir = tempfile::TempDir::new().unwrap();
889 let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
890 dir.path().to_path_buf(),
891 ))
892 .await
893 .unwrap();
894 let target = broker.listen_addr().to_string();
895
896 let mut sink = TargetSink::start(SinkParams {
900 target_bootstrap: target.clone(),
901 flow_name: "us-east__eu-west".into(),
902 source_alias: "us-east".into(),
903 delivery: Delivery::AtLeastOnce,
904 naming: crate::config::NamingPolicy::Identity,
905 target_zones: vec!["us".into()],
906 policies: vec![],
907 security: None,
908 source_partition_counts: [("orders".to_string(), 1)].into(),
909 })
910 .await
911 .unwrap();
912
913 let a = ConnectRecord::new(
915 None,
916 Some(ReplicatedRecord {
917 topic: "orders".into(),
918 partition: PartitionIndex(0),
919 offset: Offset(1),
920 timestamp: Timestamp(1),
921 key: Some("a".into()),
922 value: Some("v".into()),
923 headers: vec![(PROVENANCE_HEADER.into(), Some("us-east".into()))],
924 }),
925 );
926 let b = ConnectRecord::new(
928 None,
929 Some(ReplicatedRecord {
930 topic: "orders".into(),
931 partition: PartitionIndex(0),
932 offset: Offset(2),
933 timestamp: Timestamp(1),
934 key: Some("b".into()),
935 value: Some("v".into()),
936 headers: vec![(PROVENANCE_HEADER.into(), Some("eu-west".into()))],
937 }),
938 );
939 let c = ConnectRecord::new(
941 None,
942 Some(ReplicatedRecord {
943 topic: "orders".into(),
944 partition: PartitionIndex(0),
945 offset: Offset(3),
946 timestamp: Timestamp(1),
947 key: Some("c".into()),
948 value: Some("v".into()),
949 headers: vec![("other".into(), Some("us-east".into()))],
950 }),
951 );
952
953 sink.put(vec![a, b, c]).await.unwrap();
954 sink.flush().await.unwrap();
955
956 let produced = crate::admin_util::read_all(&target, "orders", None)
962 .await
963 .unwrap();
964 let mut keys: Vec<Vec<u8>> = produced
965 .into_iter()
966 .filter_map(|(k, _)| k.map(|b| b.to_vec()))
967 .collect();
968 keys.sort();
969 assert2::assert!(keys == vec![b"b".to_vec(), b"c".to_vec()]);
970 }
971
972 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
973 async fn exactly_once_abort_hides_data_sync_and_checkpoint_then_commit_reveals_all() {
974 let dir = tempfile::TempDir::new().unwrap();
975 let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
976 dir.path().to_path_buf(),
977 ))
978 .await
979 .unwrap();
980 let target = broker.listen_addr().to_string();
981 let flow_name = "us-east__eu-west";
982 let mut sink = TargetSink::start(SinkParams {
983 target_bootstrap: target.clone(),
984 flow_name: flow_name.into(),
985 source_alias: "us-east".into(),
986 delivery: Delivery::ExactlyOnce,
987 naming: NamingPolicy::Default,
988 target_zones: vec!["eu".into()],
989 policies: Vec::new(),
990 security: None,
991 source_partition_counts: [("orders".to_owned(), 1)].into(),
992 })
993 .await
994 .unwrap();
995 sink.set_offset_sync_observation_capacity(130);
996 let records = || {
997 (0..130)
998 .map(|offset| {
999 ConnectRecord::new(
1000 None,
1001 Some(ReplicatedRecord {
1002 topic: "orders".into(),
1003 partition: PartitionIndex(0),
1004 offset: Offset(offset),
1005 timestamp: Timestamp(1),
1006 key: Some("k".into()),
1007 value: Some("v".into()),
1008 headers: Vec::new(),
1009 }),
1010 )
1011 })
1012 .collect()
1013 };
1014
1015 sink.begin().await.unwrap();
1016 sink.put(records()).await.unwrap();
1017 let uncommitted_syncs = sink.flush_pending().await.unwrap();
1020 assert2::assert!(uncommitted_syncs.len() == 130);
1021 sink.abort().await.unwrap();
1022
1023 let store =
1024 crate::checkpoint_store::InternalTopicCheckpointStore::start(&target, flow_name, None)
1025 .await
1026 .unwrap();
1027 assert2::assert!(
1028 crate::admin_util::read_all(&target, "us-east.orders", None)
1029 .await
1030 .unwrap()
1031 .is_empty()
1032 );
1033 assert2::assert!(
1034 crate::admin_util::read_all(&target, &OffsetSync::topic_name("us-east"), None,)
1035 .await
1036 .unwrap()
1037 .is_empty()
1038 );
1039 assert2::assert!(store.load().await.unwrap().is_none());
1040 assert2::assert!(sink.drain_offset_syncs().is_empty());
1041
1042 sink.begin().await.unwrap();
1043 sink.put(records()).await.unwrap();
1044 sink.commit().await.unwrap();
1045
1046 assert2::assert!(
1047 crate::admin_util::read_all(&target, "us-east.orders", None)
1048 .await
1049 .unwrap()
1050 .len()
1051 == 130
1052 );
1053 assert2::assert!(
1054 crate::admin_util::read_all(&target, &OffsetSync::topic_name("us-east"), None,)
1055 .await
1056 .unwrap()
1057 .len()
1058 == 130
1059 );
1060 let checkpoint = store.load().await.unwrap().unwrap();
1061 assert2::assert!(checkpoint.position.get("orders-0") == Some(&OffsetValue::Long(130)));
1062 assert2::assert!(sink.drain_offset_syncs().len() == 130);
1063 }
1064}