Skip to main content

moonpool_sim/runner/
locality.rs

1//! Failure-domain locality for correlated fault injection.
2//!
3//! Locality models a `FoundationDB`-style Cluster → Datacenter → Zone → Machine →
4//! Process hierarchy so that collocated processes share fate. It is **orthogonal
5//! to tags** ([`super::tags`]): tags round-robin independent dimensions, while
6//! locality assigns *contiguous* hierarchical groups whose processes can be
7//! rebooted together (see [`AttritionScope`](super::process::AttritionScope)).
8//!
9//! # Example
10//!
11//! ```ignore
12//! // 3 datacenters × 3 zones × 3 machines × 1 process = 27 processes.
13//! .cluster(LocalityConfig::new(3, 3, 3, 1), || Box::new(MyNode::new()))
14//! ```
15
16use std::collections::HashMap;
17use std::net::IpAddr;
18
19use super::builder::ProcessCount;
20
21/// The level of a failure domain in the locality hierarchy.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum DomainLevel {
24    /// A whole datacenter.
25    Datacenter,
26    /// A zone within a datacenter.
27    Zone,
28    /// A single machine — the unit of shared fate.
29    Machine,
30}
31
32/// Resolved failure-domain locality for a single process instance.
33///
34/// Identifiers are globally unique and hierarchical (`dc1`, `dc1-z1`,
35/// `dc1-z1-m1`) so that domain queries never confuse a machine in one
36/// datacenter with a machine in another.
37#[derive(Debug, Clone, PartialEq, Eq, Default)]
38pub struct LocalityInfo {
39    datacenter: String,
40    zone: String,
41    machine: String,
42}
43
44impl LocalityInfo {
45    /// Create locality from explicit datacenter, zone, and machine ids.
46    #[must_use]
47    pub fn new(
48        datacenter: impl Into<String>,
49        zone: impl Into<String>,
50        machine: impl Into<String>,
51    ) -> Self {
52        Self {
53            datacenter: datacenter.into(),
54            zone: zone.into(),
55            machine: machine.into(),
56        }
57    }
58
59    /// The datacenter id (e.g. `dc1`).
60    #[must_use]
61    pub fn datacenter(&self) -> &str {
62        &self.datacenter
63    }
64
65    /// The zone id (e.g. `dc1-z1`).
66    #[must_use]
67    pub fn zone(&self) -> &str {
68        &self.zone
69    }
70
71    /// The machine id (e.g. `dc1-z1-m1`).
72    #[must_use]
73    pub fn machine(&self) -> &str {
74        &self.machine
75    }
76
77    /// The id at the given domain level.
78    #[must_use]
79    pub fn id_for(&self, level: DomainLevel) -> &str {
80        match level {
81            DomainLevel::Datacenter => &self.datacenter,
82            DomainLevel::Zone => &self.zone,
83            DomainLevel::Machine => &self.machine,
84        }
85    }
86}
87
88/// Configuration for laying processes out across a failure-domain topology.
89///
90/// Each dimension accepts a fixed count (`3`) or a range (`1..=3`) via
91/// [`ProcessCount`]. Ranges are sampled once per seed from the simulation RNG,
92/// so every seed exercises a different cluster shape — mirroring `FoundationDB`'s
93/// per-seed topology generation. The total process count is the product of the
94/// four sampled dimensions.
95#[derive(Debug, Clone)]
96pub struct LocalityConfig {
97    datacenters: ProcessCount,
98    zones_per_datacenter: ProcessCount,
99    machines_per_zone: ProcessCount,
100    processes_per_machine: ProcessCount,
101}
102
103impl LocalityConfig {
104    /// Create a topology config. Each argument is a fixed count (`3`) or a range
105    /// (`1..=3`) sampled per seed.
106    #[must_use]
107    pub fn new(
108        datacenters: impl Into<ProcessCount>,
109        zones_per_datacenter: impl Into<ProcessCount>,
110        machines_per_zone: impl Into<ProcessCount>,
111        processes_per_machine: impl Into<ProcessCount>,
112    ) -> Self {
113        Self {
114            datacenters: datacenters.into(),
115            zones_per_datacenter: zones_per_datacenter.into(),
116            machines_per_zone: machines_per_zone.into(),
117            processes_per_machine: processes_per_machine.into(),
118        }
119    }
120
121    /// Sample the topology for the current seed and assign one [`LocalityInfo`]
122    /// per process index via contiguous hierarchical slicing.
123    ///
124    /// Consecutive process indices share a machine, so collocation is contiguous
125    /// — the property that makes machine-scoped reboots meaningful. Each
126    /// dimension is clamped to at least 1.
127    pub(crate) fn resolve_topology(&self) -> Vec<LocalityInfo> {
128        let datacenters = self.datacenters.resolve().max(1);
129        let zones = self.zones_per_datacenter.resolve().max(1);
130        let machines = self.machines_per_zone.resolve().max(1);
131        let processes = self.processes_per_machine.resolve().max(1);
132
133        let mut out = Vec::with_capacity(datacenters * zones * machines * processes);
134        for d in 0..datacenters {
135            for z in 0..zones {
136                for m in 0..machines {
137                    let datacenter = format!("dc{}", d + 1);
138                    let zone = format!("dc{}-z{}", d + 1, z + 1);
139                    let machine = format!("dc{}-z{}-m{}", d + 1, z + 1, m + 1);
140                    for _ in 0..processes {
141                        out.push(LocalityInfo::new(
142                            datacenter.clone(),
143                            zone.clone(),
144                            machine.clone(),
145                        ));
146                    }
147                }
148            }
149        }
150        out
151    }
152}
153
154/// Registry mapping process IPs to their resolved locality.
155///
156/// Parallel to [`TagRegistry`](super::tags::TagRegistry) but for failure
157/// domains: supports shared-fate queries (all IPs on a machine, in a zone, or in
158/// a datacenter).
159#[derive(Debug, Clone, Default, PartialEq)]
160pub struct MachineRegistry {
161    ip_locality: HashMap<IpAddr, LocalityInfo>,
162}
163
164impl MachineRegistry {
165    /// Create an empty registry.
166    #[must_use]
167    pub fn new() -> Self {
168        Self {
169            ip_locality: HashMap::new(),
170        }
171    }
172
173    /// Register locality for a process IP.
174    pub fn register(&mut self, ip: IpAddr, locality: LocalityInfo) {
175        self.ip_locality.insert(ip, locality);
176    }
177
178    /// Whether any locality has been registered.
179    #[must_use]
180    pub fn is_empty(&self) -> bool {
181        self.ip_locality.is_empty()
182    }
183
184    /// Get the locality for a specific IP.
185    #[must_use]
186    pub fn locality_for(&self, ip: IpAddr) -> Option<&LocalityInfo> {
187        self.ip_locality.get(&ip)
188    }
189
190    /// Find all IPs whose domain at `level` matches `id`.
191    #[must_use]
192    pub fn ips_in_domain(&self, level: DomainLevel, id: &str) -> Vec<IpAddr> {
193        self.ip_locality
194            .iter()
195            .filter(|(_, loc)| loc.id_for(level) == id)
196            .map(|(ip, _)| *ip)
197            .collect()
198    }
199
200    /// Find all IPs on a single machine — the unit of shared fate.
201    #[must_use]
202    pub fn ips_on_machine(&self, machine_id: &str) -> Vec<IpAddr> {
203        self.ips_in_domain(DomainLevel::Machine, machine_id)
204    }
205
206    /// All distinct machine ids, sorted for determinism.
207    #[must_use]
208    pub fn all_machines(&self) -> Vec<String> {
209        self.distinct_ids(DomainLevel::Machine)
210    }
211
212    /// All distinct zone ids, sorted for determinism.
213    #[must_use]
214    pub fn all_zones(&self) -> Vec<String> {
215        self.distinct_ids(DomainLevel::Zone)
216    }
217
218    /// All distinct datacenter ids, sorted for determinism.
219    #[must_use]
220    pub fn all_datacenters(&self) -> Vec<String> {
221        self.distinct_ids(DomainLevel::Datacenter)
222    }
223
224    fn distinct_ids(&self, level: DomainLevel) -> Vec<String> {
225        let mut ids: Vec<String> = self
226            .ip_locality
227            .values()
228            .map(|loc| loc.id_for(level).to_string())
229            .collect();
230        ids.sort();
231        ids.dedup();
232        ids
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn ip(n: usize) -> IpAddr {
241        format!("10.0.1.{n}").parse().expect("valid IP")
242    }
243
244    #[test]
245    fn resolve_topology_slices_contiguously() {
246        // 2 dc × 2 zone × 2 machine × 2 proc = 16 processes.
247        let locs = LocalityConfig::new(2, 2, 2, 2).resolve_topology();
248        assert_eq!(locs.len(), 16);
249
250        // Consecutive indices share a machine (processes_per_machine = 2).
251        assert_eq!(locs[0].machine(), "dc1-z1-m1");
252        assert_eq!(locs[1].machine(), "dc1-z1-m1");
253        assert_eq!(locs[2].machine(), "dc1-z1-m2");
254
255        // Zone boundary after machines_per_zone * processes_per_machine = 4.
256        assert_eq!(locs[0].zone(), "dc1-z1");
257        assert_eq!(locs[4].zone(), "dc1-z2");
258
259        // Datacenter boundary after zones * machines * processes = 8.
260        assert_eq!(locs[7].datacenter(), "dc1");
261        assert_eq!(locs[8].datacenter(), "dc2");
262    }
263
264    #[test]
265    fn globally_unique_machine_ids_across_datacenters() {
266        let locs = LocalityConfig::new(2, 1, 1, 1).resolve_topology();
267        assert_eq!(locs.len(), 2);
268        assert_ne!(locs[0].machine(), locs[1].machine());
269        assert_eq!(locs[0].machine(), "dc1-z1-m1");
270        assert_eq!(locs[1].machine(), "dc2-z1-m1");
271    }
272
273    #[test]
274    fn registry_domain_queries() {
275        // 2 dc × 1 zone × 2 machine × 1 proc = 4 processes.
276        let locs = LocalityConfig::new(2, 1, 2, 1).resolve_topology();
277        let mut reg = MachineRegistry::new();
278        for (i, loc) in locs.iter().enumerate() {
279            reg.register(ip(i + 1), loc.clone());
280        }
281
282        assert_eq!(reg.ips_in_domain(DomainLevel::Datacenter, "dc1").len(), 2);
283        assert_eq!(reg.ips_on_machine("dc1-z1-m1").len(), 1);
284        assert_eq!(reg.all_machines().len(), 4);
285        assert_eq!(
286            reg.all_datacenters(),
287            vec!["dc1".to_string(), "dc2".to_string()]
288        );
289        assert!(!reg.is_empty());
290    }
291
292    #[test]
293    fn id_for_matches_accessors() {
294        let loc = LocalityInfo::new("dc1", "dc1-z2", "dc1-z2-m3");
295        assert_eq!(loc.id_for(DomainLevel::Datacenter), loc.datacenter());
296        assert_eq!(loc.id_for(DomainLevel::Zone), loc.zone());
297        assert_eq!(loc.id_for(DomainLevel::Machine), loc.machine());
298    }
299}