Skip to main content

krabka_replicator/
naming.rs

1//! Remote-topic naming and loop-prevention primitives.
2
3use crate::config::NamingPolicy;
4
5/// Header key that every produced record carries with the origin cluster alias.
6/// The identity-naming loop-guard uses it, and it is also useful for provenance.
7pub const PROVENANCE_HEADER: &str = "__krabka_origin";
8
9/// Maps source topic names to target topic names and enforces loop-prevention
10/// rules for active/active replication.
11#[derive(Debug, Clone)]
12pub struct Renamer {
13    policy: NamingPolicy,
14    source_alias: String,
15}
16
17impl Renamer {
18    /// Create a new [`Renamer`] for the given policy and source cluster alias.
19    #[must_use]
20    pub fn new(policy: NamingPolicy, source_alias: &str) -> Self {
21        Self {
22            policy,
23            source_alias: source_alias.to_owned(),
24        }
25    }
26
27    /// Return the name this topic should have on the target cluster.
28    ///
29    /// - [`NamingPolicy::Default`][]: `<source_alias>.<source_topic>`
30    /// - [`NamingPolicy::Identity`][]: `<source_topic>` (unchanged)
31    #[must_use]
32    pub fn target_name(&self, source_topic: &str) -> String {
33        match self.policy {
34            NamingPolicy::Default => format!("{}.{}", self.source_alias, source_topic),
35            NamingPolicy::Identity => source_topic.to_owned(),
36        }
37    }
38
39    /// Return `true` if this topic looks like a topic that replication already
40    /// produced. This flow should exclude it from its subscription, for loop
41    /// prevention.
42    ///
43    /// Under [`NamingPolicy::Default`] a topic that contains `.`, but does not
44    /// *start* with `.`, counts as remote. This mirrors the "topic contains the
45    /// replication separator" heuristic of `MirrorMaker` 2. The rule is
46    /// deliberately simple for Slice 1, and the two-cluster integration test
47    /// pins the exact behaviour.
48    ///
49    /// Under [`NamingPolicy::Identity`] the loop guard uses the header. See
50    /// [`PROVENANCE_HEADER`]. This method then always returns `false`.
51    #[must_use]
52    pub fn is_remote(&self, topic: &str) -> bool {
53        match self.policy {
54            NamingPolicy::Default => topic.contains('.') && !topic.starts_with('.'),
55            NamingPolicy::Identity => false,
56        }
57    }
58
59    /// The source cluster alias stamped in [`PROVENANCE_HEADER`] on produced
60    /// records, used for identity-policy loop prevention.
61    #[must_use]
62    pub fn provenance_alias(&self) -> &str {
63        &self.source_alias
64    }
65
66    /// The naming policy in effect.
67    #[must_use]
68    pub fn policy(&self) -> NamingPolicy {
69        self.policy
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use assert2::check;
76
77    use super::*;
78    use crate::config::NamingPolicy;
79
80    #[test]
81    fn default_policy_prefixes_with_source_alias() {
82        let n = Renamer::new(NamingPolicy::Default, "us-east");
83        assert2::assert!(n.target_name("orders") == "us-east.orders");
84        check!(n.is_remote("eu-west.billing"));
85        check!(!n.is_remote("orders"));
86    }
87
88    #[test]
89    fn identity_policy_keeps_name_and_uses_provenance() {
90        let n = Renamer::new(NamingPolicy::Identity, "us-east");
91        assert2::assert!(n.target_name("orders") == "orders");
92        check!(!n.is_remote("orders"));
93        assert2::assert!(n.provenance_alias() == "us-east");
94        // `policy()` returns the configured policy verbatim, not the Default.
95        assert2::assert!(n.policy() == NamingPolicy::Identity);
96    }
97}