Skip to main content

rustlavel_discovery/
instance.rs

1//! One running copy of a service, and what the registry knows about it.
2
3use std::time::{SystemTime, UNIX_EPOCH};
4
5/// What an instance says about itself.
6///
7/// Separate from whether the registry can still *hear* it — that is `last_seen`,
8/// and the two are different facts. An instance can be `Up` and unheard from
9/// for a minute, which is a network problem; it can be heard from every second
10/// and `OutOfService`, which is a deliberate drain before a deploy.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Status {
13    Up,
14    Down,
15    Starting,
16    /// Taken out of rotation on purpose. Still registered, still heartbeating,
17    /// and deliberately not sent traffic — this is how a deploy drains an
18    /// instance without the registry deciding it died.
19    OutOfService,
20}
21
22impl Status {
23    pub fn parse(text: &str) -> Status {
24        match text.trim().to_ascii_uppercase().as_str() {
25            "UP" => Status::Up,
26            "STARTING" => Status::Starting,
27            "OUT_OF_SERVICE" => Status::OutOfService,
28            // Anything unrecognised is `Down`, not `Up`. A status this code
29            // does not understand must not become traffic.
30            _ => Status::Down,
31        }
32    }
33
34    pub fn as_str(self) -> &'static str {
35        match self {
36            Status::Up => "UP",
37            Status::Down => "DOWN",
38            Status::Starting => "STARTING",
39            Status::OutOfService => "OUT_OF_SERVICE",
40        }
41    }
42
43    /// Whether this instance should be given traffic.
44    pub fn takes_traffic(self) -> bool {
45        self == Status::Up
46    }
47}
48
49/// A registered instance.
50#[derive(Debug, Clone)]
51pub struct Instance {
52    /// The service this belongs to. Upper-cased, because Eureka upper-cases
53    /// application names and a registry that disagrees with its own clients
54    /// about the name of a service is worse than one that shouts.
55    pub service: String,
56    /// Unique within the service. `host:port` when nothing better is given.
57    pub id: String,
58    pub host: String,
59    pub port: u16,
60    pub secure: bool,
61    pub status: Status,
62    /// Free-form, and the place zone and version live. A registry that fixed
63    /// the set of things an instance may say about itself would be a registry
64    /// somebody works around within a month.
65    pub metadata: Vec<(String, String)>,
66    /// Seconds between heartbeats, as the instance says it will send them.
67    ///
68    /// The instance decides, not the registry: only the instance knows whether
69    /// it is a service that can promise every five seconds or a batch worker
70    /// that cannot.
71    pub renew_interval: u64,
72    /// Unix seconds when the registry last heard from it.
73    pub last_seen: u64,
74}
75
76impl Instance {
77    pub fn new(service: impl Into<String>, host: impl Into<String>, port: u16) -> Instance {
78        let host = host.into();
79        let service = service.into().to_ascii_uppercase();
80        Instance {
81            id: format!("{host}:{port}"),
82            service,
83            host,
84            port,
85            secure: false,
86            status: Status::Up,
87            metadata: Vec::new(),
88            renew_interval: 30,
89            last_seen: now(),
90        }
91    }
92
93    pub fn id(mut self, id: impl Into<String>) -> Instance {
94        self.id = id.into();
95        self
96    }
97
98    pub fn secure(mut self, secure: bool) -> Instance {
99        self.secure = secure;
100        self
101    }
102
103    pub fn status(mut self, status: Status) -> Instance {
104        self.status = status;
105        self
106    }
107
108    pub fn meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Instance {
109        self.metadata.push((key.into(), value.into()));
110        self
111    }
112
113    pub fn renew_every(mut self, seconds: u64) -> Instance {
114        self.renew_interval = seconds.max(1);
115        self
116    }
117
118    /// Where to send a request.
119    pub fn url(&self) -> String {
120        let scheme = if self.secure { "https" } else { "http" };
121        format!("{scheme}://{}:{}", self.host, self.port)
122    }
123
124    pub fn zone(&self) -> Option<&str> {
125        self.meta_value("zone")
126    }
127
128    pub fn meta_value(&self, key: &str) -> Option<&str> {
129        self.metadata.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
130    }
131
132    /// Whether the registry has heard from it recently enough.
133    ///
134    /// Three missed heartbeats, not one. A single miss is a garbage collection
135    /// pause or a dropped packet; three in a row is a service that has stopped
136    /// talking. Eureka's own default is the same shape — a ninety-second lease
137    /// against a thirty-second renewal.
138    pub fn fresh_at(&self, now: u64) -> bool {
139        now.saturating_sub(self.last_seen) < self.renew_interval * 3
140    }
141}
142
143pub fn now() -> u64 {
144    SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |elapsed| elapsed.as_secs())
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    /// A status this code does not understand must not become traffic.
152    #[test]
153    fn an_unknown_status_is_down_rather_than_up() {
154        assert_eq!(Status::parse("UP"), Status::Up);
155        assert_eq!(Status::parse("up"), Status::Up);
156        assert_eq!(Status::parse("OUT_OF_SERVICE"), Status::OutOfService);
157        for rubbish in ["", "MAYBE", "ok", "1", "UNKNOWN"] {
158            assert_eq!(Status::parse(rubbish), Status::Down, "{rubbish:?}");
159        }
160    }
161
162    /// Draining before a deploy: still registered, still heartbeating, and
163    /// deliberately not sent traffic.
164    #[test]
165    fn only_up_takes_traffic() {
166        assert!(Status::Up.takes_traffic());
167        for other in [Status::Down, Status::Starting, Status::OutOfService] {
168            assert!(!other.takes_traffic(), "{:?} was given traffic", other);
169        }
170    }
171
172    /// One miss is a GC pause. Three is a service that stopped talking.
173    #[test]
174    fn freshness_allows_two_missed_heartbeats() {
175        let instance = Instance::new("orders", "10.0.0.1", 8080).renew_every(30);
176        let registered = instance.last_seen;
177
178        assert!(instance.fresh_at(registered + 29), "one interval is not a death");
179        assert!(instance.fresh_at(registered + 89), "two misses is not a death");
180        assert!(!instance.fresh_at(registered + 90), "three misses is");
181    }
182
183    #[test]
184    fn a_service_name_is_upper_cased_to_match_what_eureka_clients_send() {
185        assert_eq!(Instance::new("orders", "h", 1).service, "ORDERS");
186        assert_eq!(Instance::new("Orders", "h", 1).service, "ORDERS");
187    }
188
189    #[test]
190    fn an_instance_knows_its_own_url_and_defaults_its_id_to_host_and_port() {
191        let plain = Instance::new("orders", "10.0.0.1", 8080);
192        assert_eq!(plain.url(), "http://10.0.0.1:8080");
193        assert_eq!(plain.id, "10.0.0.1:8080");
194
195        let tls = Instance::new("orders", "orders.example.com", 443).secure(true);
196        assert_eq!(tls.url(), "https://orders.example.com:443");
197    }
198
199    #[test]
200    fn zone_is_metadata_rather_than_a_field_of_its_own() {
201        let instance = Instance::new("orders", "h", 1).meta("zone", "jakarta-a");
202        assert_eq!(instance.zone(), Some("jakarta-a"));
203        assert_eq!(Instance::new("orders", "h", 1).zone(), None);
204    }
205}