Skip to main content

dynomite/cluster/
snitch.rs

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