Skip to main content

krabka_replicator/
sink.rs

1//! Sink side: residency gate, naming, and provenance loop-guard, then produce
2//! to the target and emit source-to-target offset syncs.
3
4use 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/// Optional diagnostics, independent of the durable offset-sync records.
31#[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
65/// One in-flight produce awaiting its broker ack: the delivery handle plus the
66/// source-side coordinates needed to build the [`OffsetSync`] once the ack
67/// supplies the downstream offset.
68struct PendingProduce {
69    /// Handle for the broker ack carrying downstream record metadata.
70    rx: DeliveryHandle,
71    /// Source topic name.
72    topic: String,
73    /// Source partition index.
74    partition: PartitionIndex,
75    /// Source (upstream) offset of the record being produced.
76    upstream: UpstreamOffset,
77}
78
79/// Parameters required to start a [`TargetSink`].
80pub struct SinkParams {
81    /// Bootstrap address of the target cluster.
82    pub target_bootstrap: String,
83    /// Stable flow name used as the transaction and checkpoint identity.
84    pub flow_name: String,
85    /// Alias of the source cluster (stamped as provenance header).
86    pub source_alias: String,
87    /// Delivery guarantee for this flow.
88    pub delivery: Delivery,
89    /// How to rename source topics on the target.
90    pub naming: NamingPolicy,
91    /// Compliance zones of the target cluster (used for residency checks).
92    pub target_zones: Vec<String>,
93    /// Residency policies to enforce.
94    pub policies: Vec<PolicyConfig>,
95    /// Optional TLS/SASL security for the target cluster.
96    pub security: Option<krabka_client_core::security::ClientSecurity>,
97    /// Source partition count by source topic.
98    pub source_partition_counts: BTreeMap<String, i32>,
99}
100
101/// A [`Sink`] that applies residency filtering, topic renaming, and loop-guard
102/// logic before it produces records to the target cluster.
103///
104/// At-least-once flows drain pending acknowledgements and write MM2-compatible
105/// offset-syncs on [`flush`](Sink::flush). Exactly-once flows put target data,
106/// offset-syncs, and the source checkpoint in one producer transaction and make
107/// all three visible together on [`commit`](Sink::commit).
108pub 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    /// Bootstrap address of the target cluster, used for lazy topic creation.
118    target_bootstrap: String,
119    /// Security config retained for lazy topic creation calls.
120    security: Option<krabka_client_core::security::ClientSecurity>,
121    client_resource_policy: ClientResourcePolicy,
122    runtime_policy: ReplicatorRuntimePolicy,
123    source_partition_counts: BTreeMap<String, i32>,
124    /// Target topics that the sink has already ensured, so it makes no
125    /// redundant admin calls.
126    created_topics: HashSet<String>,
127    /// In-flight produces awaiting broker acks (see [`PendingProduce`]).
128    pending: Vec<PendingProduce>,
129    /// Transaction guard held across the connect runtime's begin/put/commit calls.
130    transaction: Option<OwnedTransaction>,
131    /// Last source position committed in a target transaction.
132    committed_checkpoint: SourceOffset,
133    /// Source position staged by the current transaction.
134    pending_checkpoint: Option<SourceOffset>,
135    /// Bounded, opt-in diagnostics; never used to publish or checkpoint syncs.
136    offset_syncs: OffsetSyncObservations,
137}
138
139impl TargetSink {
140    /// Build a [`TargetSink`], ensure the offset-syncs topic exists, and connect
141    /// the producer to the target cluster.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`ConnectError::Backend`] if the producer cannot connect or the
146    /// offset-syncs topic cannot be created.
147    #[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    /// Start with the deployment's client resource policy.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`ConnectError::Backend`] if the producer cannot connect or the
162    /// offset-syncs topic cannot be created.
163    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(&params.source_alias);
181
182        // Ensure the offset-syncs topic exists before we start producing.
183        crate::admin_util::ensure_topic_with_runtime_policy(
184            &params.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                &params.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        // Exactly-once flows use one stable transactional producer per flow.
208        let producer = build_producer(
209            &params.target_bootstrap,
210            &params.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                &params.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, &params.source_alias);
242
243        let gate = ResidencyGate::compile(&params.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    /// Retain at most `capacity` recent completed offset-syncs for observation.
270    ///
271    /// Observation is disabled by default. A zero capacity disables it and
272    /// releases retained records. Older observations are dropped when the
273    /// capacity is exceeded; durable sync publication is unaffected.
274    pub fn set_offset_sync_observation_capacity(&mut self, capacity: usize) {
275        self.offset_syncs.set_capacity(capacity);
276    }
277
278    /// Return and clear retained [`OffsetSync`] observations, oldest first.
279    ///
280    /// Returns an empty vector unless observation was explicitly enabled with
281    /// [`Self::set_offset_sync_observation_capacity`]. Only successful flushes
282    /// and committed transactions become observable.
283    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        // Keep acknowledgement memory bounded while allowing the producer to
309        // batch sync records instead of paying one delivery round trip per row.
310        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            // Confirm every sync before returning to checkpoint or transaction
344            // commit. An enqueue alone does not establish durable delivery.
345            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
381/// Build the target producer for the selected delivery guarantee.
382async 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    /// Accept a batch of replicated records, apply filtering and loop-guard
432    /// logic, then enqueue produce calls for the accepted records.
433    ///
434    /// The sink drops a record, and does not buffer it, when:
435    /// - `value` is `None`, that is, a tombstone or no payload.
436    /// - The identity-naming loop-guard fires: the `__krabka_origin` header of
437    ///   the record matches our own `source_alias`.
438    /// - The residency gate blocks the topic for the zones of the target.
439    #[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            // Identity-naming loop-guard: skip if already produced by us.
460            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            // Residency gate.
472            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            // Build target topic name.
482            let target_topic = self.renamer.target_name(&r.topic);
483
484            // Lazily ensure the target topic exists (no-op after first visit).
485            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            // Build headers: original headers + provenance.
511            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            // Enqueue the produce and pair with an in-flight OffsetSync.
525            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                // The record's source offset is the upstream side of the sync.
543                upstream: r.offset.into(),
544            });
545            accepted += 1;
546        }
547
548        tracing::Span::current().record("accepted", accepted);
549        Ok(())
550    }
551
552    /// Await all pending produce acks, write offset-syncs to the target, and
553    /// flush the producer.
554    #[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                        // Each earlier transaction contributes one control
765                        // marker in the target data partition.
766                        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        // Identity naming (no rename) + permit-all residency. The loop-guard
897        // must skip ONLY a record whose `__krabka_origin` header equals our own
898        // source alias.
899        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        // A: our own provenance -> loop -> MUST be skipped.
914        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        // B: different origin -> MUST be produced.
927        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        // C: a non-provenance header carrying our alias -> MUST be produced.
940        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        // Identity naming means the target topic is "orders" (no prefix). Read the
957        // produced records back and assert the EXACT set of keys: B and C land,
958        // A (our own provenance) is filtered. Checking the key set — not just the
959        // count — is what distinguishes the four loop-guard mutants, since some of
960        // them merely swap *which* record is skipped while keeping the count at 2.
961        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        // Exercise visibility after both the data and sync deliveries have
1018        // completed, while their enclosing transaction is still uncommitted.
1019        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}