Skip to main content

a3s_box_runtime/scale/
registry.rs

1//! Instance registry for multi-node deployments.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8/// Registry for instance self-registration in standalone multi-node deployments.
9///
10/// Each Box host registers its instances with the registry so Gateway
11/// can discover endpoints for traffic routing.
12pub struct InstanceRegistry {
13    /// Registered instances: instance_id → registration
14    pub(super) entries: HashMap<String, RegistryEntry>,
15}
16
17/// A registered instance entry.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub(super) struct RegistryEntry {
20    pub(super) instance_id: String,
21    pub(super) service: String,
22    pub(super) endpoint: String,
23    pub(super) host_id: String,
24    pub(super) metadata: HashMap<String, String>,
25    pub(super) registered_at: DateTime<Utc>,
26    pub(super) last_heartbeat: DateTime<Utc>,
27}
28
29impl Default for InstanceRegistry {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl InstanceRegistry {
36    /// Create a new empty registry.
37    pub fn new() -> Self {
38        Self {
39            entries: HashMap::new(),
40        }
41    }
42
43    /// Register an instance.
44    pub fn register(
45        &mut self,
46        instance_id: &str,
47        service: &str,
48        endpoint: &str,
49        host_id: &str,
50        metadata: HashMap<String, String>,
51    ) {
52        let now = Utc::now();
53        self.entries.insert(
54            instance_id.to_string(),
55            RegistryEntry {
56                instance_id: instance_id.to_string(),
57                service: service.to_string(),
58                endpoint: endpoint.to_string(),
59                host_id: host_id.to_string(),
60                metadata,
61                registered_at: now,
62                last_heartbeat: now,
63            },
64        );
65    }
66
67    /// Deregister an instance.
68    pub fn deregister(&mut self, instance_id: &str) -> bool {
69        self.entries.remove(instance_id).is_some()
70    }
71
72    /// Record a heartbeat for an instance.
73    pub fn heartbeat(&mut self, instance_id: &str) -> bool {
74        if let Some(entry) = self.entries.get_mut(instance_id) {
75            entry.last_heartbeat = Utc::now();
76            true
77        } else {
78            false
79        }
80    }
81
82    /// Get all endpoints for a service (for load balancing).
83    pub fn endpoints(&self, service: &str) -> Vec<String> {
84        self.entries
85            .values()
86            .filter(|e| e.service == service)
87            .map(|e| e.endpoint.clone())
88            .collect()
89    }
90
91    /// Get all instances for a service.
92    pub fn instances_for_service(&self, service: &str) -> Vec<&str> {
93        self.entries
94            .values()
95            .filter(|e| e.service == service)
96            .map(|e| e.instance_id.as_str())
97            .collect()
98    }
99
100    /// Get all instances on a specific host.
101    pub fn instances_on_host(&self, host_id: &str) -> Vec<&str> {
102        self.entries
103            .values()
104            .filter(|e| e.host_id == host_id)
105            .map(|e| e.instance_id.as_str())
106            .collect()
107    }
108
109    /// Remove stale entries that haven't sent a heartbeat within the given duration.
110    pub fn evict_stale(&mut self, max_age: chrono::Duration) -> Vec<String> {
111        let cutoff = Utc::now() - max_age;
112        let stale: Vec<String> = self
113            .entries
114            .iter()
115            .filter(|(_, e)| e.last_heartbeat < cutoff)
116            .map(|(id, _)| id.clone())
117            .collect();
118
119        for id in &stale {
120            self.entries.remove(id);
121        }
122        stale
123    }
124
125    /// Total number of registered instances.
126    pub fn len(&self) -> usize {
127        self.entries.len()
128    }
129
130    /// Whether the registry is empty.
131    pub fn is_empty(&self) -> bool {
132        self.entries.is_empty()
133    }
134
135    /// List all unique services in the registry.
136    pub fn services(&self) -> Vec<String> {
137        let mut svcs: Vec<String> = self.entries.values().map(|e| e.service.clone()).collect();
138        svcs.sort();
139        svcs.dedup();
140        svcs
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn metadata(tier: &str) -> HashMap<String, String> {
149        HashMap::from([("tier".to_string(), tier.to_string())])
150    }
151
152    #[test]
153    fn register_indexes_instances_by_service_and_host() {
154        let mut registry = InstanceRegistry::new();
155
156        registry.register(
157            "box-1",
158            "search",
159            "http://10.0.0.1:8080",
160            "host-a",
161            metadata("blue"),
162        );
163        registry.register(
164            "box-2",
165            "search",
166            "http://10.0.0.2:8080",
167            "host-b",
168            metadata("green"),
169        );
170        registry.register(
171            "box-3",
172            "gateway",
173            "http://10.0.0.3:8080",
174            "host-a",
175            metadata("edge"),
176        );
177
178        assert_eq!(registry.len(), 3);
179
180        let mut search_endpoints = registry.endpoints("search");
181        search_endpoints.sort();
182        assert_eq!(
183            search_endpoints,
184            vec![
185                "http://10.0.0.1:8080".to_string(),
186                "http://10.0.0.2:8080".to_string()
187            ]
188        );
189
190        let mut host_a = registry.instances_on_host("host-a");
191        host_a.sort();
192        assert_eq!(host_a, vec!["box-1", "box-3"]);
193
194        let mut services = registry.services();
195        services.sort();
196        assert_eq!(services, vec!["gateway".to_string(), "search".to_string()]);
197    }
198
199    #[test]
200    fn register_replaces_existing_instance_record() {
201        let mut registry = InstanceRegistry::new();
202
203        registry.register(
204            "box-1",
205            "old",
206            "http://10.0.0.1:8080",
207            "host-a",
208            metadata("old"),
209        );
210        registry.register(
211            "box-1",
212            "new",
213            "http://10.0.0.9:9090",
214            "host-b",
215            metadata("new"),
216        );
217
218        assert_eq!(registry.len(), 1);
219        assert!(registry.endpoints("old").is_empty());
220        assert_eq!(
221            registry.endpoints("new"),
222            vec!["http://10.0.0.9:9090".to_string()]
223        );
224        assert_eq!(registry.instances_on_host("host-b"), vec!["box-1"]);
225        assert_eq!(
226            registry.entries["box-1"].metadata.get("tier"),
227            Some(&"new".to_string())
228        );
229    }
230
231    #[test]
232    fn heartbeat_and_deregister_report_whether_instance_exists() {
233        let mut registry = InstanceRegistry::new();
234        registry.register(
235            "box-1",
236            "search",
237            "http://10.0.0.1:8080",
238            "host-a",
239            metadata("blue"),
240        );
241        let before = registry.entries["box-1"].last_heartbeat;
242
243        assert!(registry.heartbeat("box-1"));
244        assert!(registry.entries["box-1"].last_heartbeat >= before);
245        assert!(!registry.heartbeat("missing"));
246
247        assert!(registry.deregister("box-1"));
248        assert!(registry.is_empty());
249        assert!(!registry.deregister("box-1"));
250    }
251
252    #[test]
253    fn evict_stale_removes_only_expired_entries() {
254        let mut registry = InstanceRegistry::new();
255        registry.register(
256            "fresh",
257            "search",
258            "http://10.0.0.1:8080",
259            "host-a",
260            metadata("fresh"),
261        );
262        registry.register(
263            "stale",
264            "search",
265            "http://10.0.0.2:8080",
266            "host-b",
267            metadata("stale"),
268        );
269        registry.entries.get_mut("stale").unwrap().last_heartbeat =
270            Utc::now() - chrono::Duration::minutes(10);
271
272        let evicted = registry.evict_stale(chrono::Duration::minutes(5));
273
274        assert_eq!(evicted, vec!["stale".to_string()]);
275        assert!(registry.entries.contains_key("fresh"));
276        assert!(!registry.entries.contains_key("stale"));
277    }
278}