Skip to main content

dynomite/cluster/
snitch.rs

1//! Node snitch: environment-driven address resolution and rack
2//! proximity helpers.
3//!
4//! The reference engine's snitch module is intentionally small:
5//! it caches the local node's broadcast address, public hostname,
6//! public IPv4, and private IPv4, looked up from environment
7//! variables (`EC2_*` in the AWS environment, plain
8//! `PUBLIC_*`/`LOCAL_IPV4` otherwise) with a fallback to the
9//! first peer's name. The proximity ordering used by the
10//! dispatcher (`pick_target_rack`, `rack_distance`) is not part
11//! of the reference snitch unit at all; the reference engine's
12//! only DC/rack proximity decision lives in the peer-side
13//! `preselect_remote_rack_for_replication` routine. Per AGENTS.md
14//! non-negotiable #6 we honor that source: this module ports the
15//! env-var/hostname helpers and adds a small set of pure
16//! rack-distance utilities used by [`crate::cluster::dispatch`].
17//! The proximity helpers are flagged as a Stage-10 deviation in
18//! `docs/parity.md` because the original brief asked for them.
19//!
20//! # Examples
21//!
22//! ```
23//! use dynomite::cluster::snitch::{rack_distance, RackDistance};
24//! assert_eq!(rack_distance("dc1", "r1", "dc1", "r1"), RackDistance::Same);
25//! assert_eq!(rack_distance("dc1", "r1", "dc1", "r2"), RackDistance::SameDc);
26//! assert_eq!(rack_distance("dc1", "r1", "dc2", "r1"), RackDistance::Remote);
27//! ```
28
29use std::env;
30
31/// Default environment string the reference engine treats as
32/// "non-AWS" (mirrors `CONF_DEFAULT_ENV`).
33pub const DEFAULT_ENV: &str = "aws";
34
35/// Coarse-grained proximity classification used by the dispatcher
36/// to order replica candidates.
37#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
38pub enum RackDistance {
39    /// Same datacenter, same rack.
40    Same,
41    /// Same datacenter, different rack.
42    SameDc,
43    /// Different datacenter.
44    Remote,
45}
46
47impl RackDistance {
48    /// Numeric distance in `0..=2` for sorting.
49    ///
50    /// # Examples
51    ///
52    /// ```
53    /// use dynomite::cluster::snitch::RackDistance;
54    /// assert!(RackDistance::Same.cost() < RackDistance::SameDc.cost());
55    /// ```
56    #[must_use]
57    pub fn cost(self) -> u8 {
58        match self {
59            RackDistance::Same => 0,
60            RackDistance::SameDc => 1,
61            RackDistance::Remote => 2,
62        }
63    }
64}
65
66/// Compute the [`RackDistance`] between `(self_dc, self_rack)` and
67/// `(other_dc, other_rack)`.
68///
69/// # Examples
70///
71/// ```
72/// use dynomite::cluster::snitch::{rack_distance, RackDistance};
73/// assert_eq!(rack_distance("a", "1", "a", "1"), RackDistance::Same);
74/// ```
75#[must_use]
76pub fn rack_distance(
77    self_dc: &str,
78    self_rack: &str,
79    other_dc: &str,
80    other_rack: &str,
81) -> RackDistance {
82    if self_dc != other_dc {
83        RackDistance::Remote
84    } else if self_rack != other_rack {
85        RackDistance::SameDc
86    } else {
87        RackDistance::Same
88    }
89}
90
91/// Pick a rack name from `candidates` that is closest to
92/// `(self_dc, self_rack)`.
93///
94/// Returns the first candidate at the smallest distance. `None` if
95/// the candidate list is empty.
96///
97/// # Examples
98///
99/// ```
100/// use dynomite::cluster::snitch::pick_target_rack;
101/// let cands = [("dc1", "r1"), ("dc1", "r2"), ("dc2", "r1")];
102/// assert_eq!(pick_target_rack("dc1", "r2", &cands), Some(("dc1", "r2")));
103/// ```
104#[must_use]
105pub fn pick_target_rack<'a>(
106    self_dc: &str,
107    self_rack: &str,
108    candidates: &'a [(&'a str, &'a str)],
109) -> Option<(&'a str, &'a str)> {
110    let mut best: Option<(RackDistance, (&str, &str))> = None;
111    for &(dc, rack) in candidates {
112        let d = rack_distance(self_dc, self_rack, dc, rack);
113        match best {
114            Some((bd, _)) if bd.cost() <= d.cost() => {}
115            _ => best = Some((d, (dc, rack))),
116        }
117    }
118    best.map(|(_, p)| p)
119}
120
121/// Whether the supplied environment label equals
122/// [`DEFAULT_ENV`].
123///
124/// # Examples
125///
126/// ```
127/// use dynomite::cluster::snitch::{is_aws_env, DEFAULT_ENV};
128/// assert!(is_aws_env(DEFAULT_ENV));
129/// assert!(!is_aws_env("baremetal"));
130/// ```
131#[must_use]
132pub fn is_aws_env(env_label: &str) -> bool {
133    env_label.starts_with(DEFAULT_ENV)
134}
135
136/// Look up the broadcast address from environment variables, then
137/// fall back to `peer_name_fallback` (the first peer's name in the
138/// reference engine).
139///
140/// Mirrors `get_broadcast_address`.
141///
142/// # Examples
143///
144/// ```
145/// use dynomite::cluster::snitch::broadcast_address;
146/// // With no env vars set, falls back to the supplied peer name.
147/// assert_eq!(
148///     broadcast_address("baremetal", "127.0.0.1", &mut |_| None),
149///     "127.0.0.1",
150/// );
151/// ```
152pub fn broadcast_address(
153    env_label: &str,
154    peer_name_fallback: &str,
155    lookup_env: &mut dyn FnMut(&str) -> Option<String>,
156) -> String {
157    let key = if is_aws_env(env_label) {
158        "EC2_PUBLIC_HOSTNAME"
159    } else {
160        "PUBLIC_HOSTNAME"
161    };
162    if let Some(v) = lookup_env(key) {
163        return v;
164    }
165    peer_name_fallback.to_string()
166}
167
168/// Look up the public hostname; mirrors `get_public_hostname`. The
169/// fallback is the peer's `name` field if it does not begin with a
170/// digit.
171pub fn public_hostname(
172    env_label: &str,
173    peer_name_fallback: &str,
174    lookup_env: &mut dyn FnMut(&str) -> Option<String>,
175) -> Option<String> {
176    let key = if is_aws_env(env_label) {
177        "EC2_PUBLIC_HOSTNAME"
178    } else {
179        "PUBLIC_HOSTNAME"
180    };
181    if let Some(v) = lookup_env(key) {
182        return Some(v);
183    }
184    let first = peer_name_fallback.bytes().next()?;
185    if first.is_ascii_digit() {
186        None
187    } else {
188        Some(peer_name_fallback.to_string())
189    }
190}
191
192/// Look up the public IPv4 address; mirrors `get_public_ip4`. The
193/// fallback is the peer's `name` if it begins with a digit.
194pub fn public_ip4(
195    env_label: &str,
196    peer_name_fallback: &str,
197    lookup_env: &mut dyn FnMut(&str) -> Option<String>,
198) -> Option<String> {
199    let key = if is_aws_env(env_label) {
200        "EC2_PUBLIC_IPV4"
201    } else {
202        "PUBLIC_IPV4"
203    };
204    if let Some(v) = lookup_env(key) {
205        return Some(v);
206    }
207    let first = peer_name_fallback.bytes().next()?;
208    if first.is_ascii_digit() {
209        Some(peer_name_fallback.to_string())
210    } else {
211        None
212    }
213}
214
215/// Look up the private IPv4 address; mirrors `get_private_ip4`.
216/// Returns `None` when neither environment variable is set (the
217/// reference engine returns `NULL` in that case).
218pub fn private_ip4(
219    env_label: &str,
220    lookup_env: &mut dyn FnMut(&str) -> Option<String>,
221) -> Option<String> {
222    let key = if is_aws_env(env_label) {
223        "EC2_LOCAL_IPV4"
224    } else {
225        "LOCAL_IPV4"
226    };
227    lookup_env(key)
228}
229
230/// Convenience that reads from the real process environment via
231/// [`std::env::var`].
232///
233/// # Examples
234///
235/// ```
236/// use dynomite::cluster::snitch::process_env_lookup;
237/// // The closure is `FnMut` and reads the live environment.
238/// let mut f = process_env_lookup();
239/// // PATH is almost always set; if not, the closure simply returns None.
240/// let _ = f("PATH");
241/// ```
242pub fn process_env_lookup() -> impl FnMut(&str) -> Option<String> {
243    |key: &str| env::var(key).ok()
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn distance_orders_correctly() {
252        assert_eq!(rack_distance("dc", "r", "dc", "r"), RackDistance::Same);
253        assert_eq!(rack_distance("dc", "r", "dc", "x"), RackDistance::SameDc);
254        assert_eq!(rack_distance("dc", "r", "dx", "r"), RackDistance::Remote);
255    }
256
257    #[test]
258    fn pick_target_rack_prefers_local_rack() {
259        let cands = [("dc", "r"), ("dc", "x"), ("d2", "r")];
260        let pick = pick_target_rack("dc", "r", &cands);
261        assert_eq!(pick, Some(("dc", "r")));
262    }
263
264    #[test]
265    fn pick_target_rack_falls_back_to_same_dc() {
266        let cands = [("dc", "x"), ("d2", "r")];
267        let pick = pick_target_rack("dc", "r", &cands);
268        assert_eq!(pick, Some(("dc", "x")));
269    }
270
271    #[test]
272    fn pick_target_rack_falls_back_to_remote() {
273        let cands = [("d2", "r")];
274        let pick = pick_target_rack("dc", "r", &cands);
275        assert_eq!(pick, Some(("d2", "r")));
276    }
277
278    #[test]
279    fn pick_target_rack_empty() {
280        let cands: [(&str, &str); 0] = [];
281        let pick = pick_target_rack("dc", "r", &cands);
282        assert!(pick.is_none());
283    }
284
285    #[test]
286    fn broadcast_uses_env_first() {
287        let mut envs = |k: &str| {
288            if k == "EC2_PUBLIC_HOSTNAME" {
289                Some("ec2-host".into())
290            } else {
291                None
292            }
293        };
294        assert_eq!(broadcast_address("aws", "fb", &mut envs), "ec2-host");
295    }
296
297    #[test]
298    fn broadcast_falls_back_to_peer_name() {
299        let mut envs = |_: &str| None;
300        assert_eq!(
301            broadcast_address("aws", "127.0.0.1", &mut envs),
302            "127.0.0.1"
303        );
304    }
305
306    #[test]
307    fn public_hostname_skips_numeric_fallback() {
308        let mut envs = |_: &str| None;
309        assert!(public_hostname("baremetal", "1.2.3.4", &mut envs).is_none());
310        assert_eq!(
311            public_hostname("baremetal", "host.dns", &mut envs).as_deref(),
312            Some("host.dns"),
313        );
314    }
315
316    #[test]
317    fn public_ip4_skips_dns_fallback() {
318        let mut envs = |_: &str| None;
319        assert!(public_ip4("aws", "host.dns", &mut envs).is_none());
320        assert_eq!(
321            public_ip4("aws", "10.0.0.1", &mut envs).as_deref(),
322            Some("10.0.0.1"),
323        );
324    }
325}