Skip to main content

crabka_replicator/
sink.rs

1//! Sink side: residency gate + naming + provenance loop-guard, produce to target,
2//! and emit source->target offset syncs.
3
4use 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
21/// One in-flight produce awaiting its broker ack: the ack receiver plus the
22/// source-side `(topic, partition, upstream offset)` coordinates needed to build
23/// the [`OffsetSync`] once the ack supplies the downstream offset.
24type PendingProduce = (
25    Receiver<Result<RecordMetadata, ProducerError>>,
26    String,
27    i32,
28    i64,
29);
30
31/// Parameters required to start a [`TargetSink`].
32pub struct SinkParams {
33    /// Bootstrap address of the target cluster.
34    pub target_bootstrap: String,
35    /// Alias of the source cluster (stamped as provenance header).
36    pub source_alias: String,
37    /// How to rename source topics on the target.
38    pub naming: NamingPolicy,
39    /// Compliance zones of the target cluster (used for residency checks).
40    pub target_zones: Vec<String>,
41    /// Residency policies to enforce.
42    pub policies: Vec<PolicyConfig>,
43    /// Optional TLS/SASL security for the target cluster.
44    pub security: Option<crabka_client_core::security::ClientSecurity>,
45}
46
47/// A [`Sink`] that applies residency filtering, topic renaming, and
48/// loop-guard logic before producing records to the target cluster.
49///
50/// On each [`flush`](Sink::flush) it drains pending produce acknowledgements,
51/// sets the downstream offset on each [`OffsetSync`], and writes those syncs
52/// back to the target's offset-syncs topic (MM2-compatible).
53pub 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    /// Bootstrap address of the target cluster (used to lazily create topics).
61    target_bootstrap: String,
62    /// Security config retained for lazy topic creation calls.
63    security: Option<crabka_client_core::security::ClientSecurity>,
64    /// Target topics that have already been ensured (to avoid redundant admin calls).
65    created_topics: HashSet<String>,
66    /// In-flight produces awaiting broker acks (see [`PendingProduce`]).
67    pending: Vec<PendingProduce>,
68    /// Completed offset-syncs, accessible via [`drain_offset_syncs`].
69    offset_syncs: Vec<OffsetSync>,
70}
71
72impl TargetSink {
73    /// Build a [`TargetSink`], ensure the offset-syncs topic exists, and connect
74    /// the producer to the target cluster.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`ConnectError::Backend`] if the producer cannot connect or the
79    /// offset-syncs topic cannot be created.
80    pub async fn start(params: SinkParams) -> Result<Self, ConnectError> {
81        let offset_syncs_topic = OffsetSync::topic_name(&params.source_alias);
82
83        // Ensure the offset-syncs topic exists before we start producing.
84        crate::admin_util::ensure_topic(
85            &params.target_bootstrap,
86            &offset_syncs_topic,
87            1,
88            params.security.clone(),
89        )
90        .await
91        .map_err(ConnectError::Backend)?;
92
93        // Build the producer (non-idempotent for at-least-once Slice 1).
94        let producer = build_producer(&params.target_bootstrap, params.security.clone())
95            .await
96            .map_err(|e| ConnectError::Backend(e.to_string()))?;
97
98        let renamer = Renamer::new(params.naming, &params.source_alias);
99
100        let gate = ResidencyGate::compile(&params.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    /// Return and clear all completed [`OffsetSync`] records accumulated since
119    /// the last call (or since construction).
120    pub fn drain_offset_syncs(&mut self) -> Vec<OffsetSync> {
121        std::mem::take(&mut self.offset_syncs)
122    }
123}
124
125/// Build a non-idempotent producer with `acks=All`.
126async 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    /// Accept a batch of replicated records, applying filtering and loop-guard
143    /// logic, then enqueue produce calls for the accepted records.
144    ///
145    /// Records are dropped (not buffered) when:
146    /// - `value` is `None` (tombstone or no payload).
147    /// - Identity-naming loop-guard fires: the record's `__crabka_origin` header
148    ///   matches our own `source_alias`.
149    /// - Residency gate blocks the topic for the target's zones.
150    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            // Identity-naming loop-guard: skip if already produced by us.
158            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            // Residency gate.
170            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            // Build target topic name.
180            let target_topic = self.renamer.target_name(&r.topic);
181
182            // Lazily ensure the target topic exists (no-op after first visit).
183            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            // Build headers: original headers + provenance.
196            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            // Enqueue the produce and pair with an in-flight OffsetSync.
210            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    /// Await all pending produce acks, write offset-syncs to the target, and
229    /// flush the producer.
230    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            // Await the broker ack for this produce.
235            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            // The downstream offset is only known once the broker acks, so the
241            // full OffsetSync is assembled here rather than carrying a
242            // placeholder through `pending`.
243            let offset_sync = OffsetSync {
244                topic,
245                partition,
246                upstream,
247                downstream: meta.offset,
248            };
249
250            // Write the offset-sync record to the target cluster.
251            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        // Identity naming (no rename) + permit-all residency. The loop-guard
365        // must skip ONLY a record whose `__crabka_origin` header equals our own
366        // source alias.
367        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        // A: our own provenance -> loop -> MUST be skipped.
379        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        // B: different origin -> MUST be produced.
392        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        // C: a non-provenance header carrying our alias -> MUST be produced.
405        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        // Identity naming means the target topic is "orders" (no prefix). Read the
422        // produced records back and assert the EXACT set of keys: B and C land,
423        // A (our own provenance) is filtered. Checking the key set — not just the
424        // count — is what distinguishes the four loop-guard mutants, since some of
425        // them merely swap *which* record is skipped while keeping the count at 2.
426        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}