1use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8pub struct InstanceRegistry {
13 pub(super) entries: HashMap<String, RegistryEntry>,
15}
16
17#[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 pub fn new() -> Self {
38 Self {
39 entries: HashMap::new(),
40 }
41 }
42
43 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 pub fn deregister(&mut self, instance_id: &str) -> bool {
69 self.entries.remove(instance_id).is_some()
70 }
71
72 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 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 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 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 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 pub fn len(&self) -> usize {
127 self.entries.len()
128 }
129
130 pub fn is_empty(&self) -> bool {
132 self.entries.is_empty()
133 }
134
135 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}