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}