1use std::time::Duration;
24
25use rustlavel_client::{Client as Http, RequestBuilder};
26use rustlavel_core::Json;
27use rustlavel_http::Method;
28
29use crate::eureka;
30use crate::instance::{Instance, Status};
31
32pub const REPLICATION_QUERY: &str = "isReplication=true";
34
35pub const REPLICATION_HEADER: &str = "x-rustlavel-replication";
38
39#[derive(Debug, Clone)]
41pub enum Change {
42 Registered(Instance),
43 Renewed { service: String, id: String },
49 Cancelled { service: String, id: String },
50 StatusChanged { service: String, id: String, status: Status },
51}
52
53impl Change {
54 fn method(&self) -> Method {
55 match self {
56 Change::Registered(_) => Method::Post,
57 Change::Renewed { .. } | Change::StatusChanged { .. } => Method::Put,
58 Change::Cancelled { .. } => Method::Delete,
59 }
60 }
61
62 fn path(&self) -> String {
63 let app = |service: &str| service.to_ascii_uppercase();
64 match self {
65 Change::Registered(instance) => format!("/eureka/apps/{}", app(&instance.service)),
66 Change::Renewed { service, id } | Change::Cancelled { service, id } => {
67 format!("/eureka/apps/{}/{id}", app(service))
68 }
69 Change::StatusChanged { service, id, status } => {
70 format!("/eureka/apps/{}/{id}/status?value={}", app(service), status.as_str())
71 }
72 }
73 }
74
75 fn body(&self) -> Option<Json> {
76 match self {
77 Change::Registered(instance) => {
78 Some(Json::object([("instance", eureka::instance_json(instance))]))
79 }
80 _ => None,
81 }
82 }
83}
84
85pub fn is_replication(marker: Option<&str>, header: Option<&str>) -> bool {
98 [marker, header]
99 .into_iter()
100 .flatten()
101 .any(|value| value.trim().eq_ignore_ascii_case("true"))
103}
104
105#[derive(Clone)]
107pub struct Peers {
108 addresses: Vec<String>,
109 http: Http,
110}
111
112impl Peers {
113 pub fn new(addresses: Vec<String>) -> Peers {
114 Peers {
115 addresses,
116 http: Http::new().timeout(Duration::from_secs(5)),
120 }
121 }
122
123 pub fn excluding(mut self, own: &str) -> Peers {
129 let own = own.trim_end_matches('/');
130 self.addresses.retain(|address| address.trim_end_matches('/') != own);
131 self
132 }
133
134 pub fn http(mut self, http: Http) -> Peers {
135 self.http = http;
136 self
137 }
138
139 pub fn addresses(&self) -> &[String] {
140 &self.addresses
141 }
142
143 fn request(&self, peer: &str, change: &Change) -> RequestBuilder {
144 let path = change.path();
145 let separator = if path.contains('?') { '&' } else { '?' };
146 let url = format!("{}{path}{separator}{REPLICATION_QUERY}", peer.trim_end_matches('/'));
147
148 let mut request =
149 self.http.request(change.method(), url).header(REPLICATION_HEADER, "true");
150 if let Some(body) = change.body() {
151 request = request.json(body);
152 }
153 request
154 }
155
156 pub async fn send(&self, change: &Change) -> usize {
163 let mut accepted = 0;
164
165 for peer in &self.addresses {
166 match self.request(peer, change).send().await {
167 Ok(response) if response.is_success() => accepted += 1,
168 Ok(response) => rustlavel_core::warn!(
169 "discovery: {peer} refused a replicated change: HTTP {}",
170 response.status
171 ),
172 Err(error) => {
173 rustlavel_core::warn!("discovery: {peer} is unreachable: {error}")
174 }
175 }
176 }
177
178 accepted
179 }
180
181 pub fn spread(&self, change: Change) {
188 if self.addresses.is_empty() {
189 return;
190 }
191 let peers = self.clone();
192 tokio::spawn(async move {
193 peers.send(&change).await;
194 });
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use rustlavel_client::{Fake, FakeResponse};
202
203 fn peers(addresses: &[&str]) -> Peers {
204 Peers::new(addresses.iter().map(|a| a.to_string()).collect())
205 }
206
207 #[test]
210 fn a_forwarded_change_is_marked_both_ways() {
211 let request = peers(&["http://b:8761"])
212 .request("http://b:8761", &Change::Registered(Instance::new("orders", "h", 80)));
213
214 assert!(request.url().contains(REPLICATION_QUERY), "{}", request.url());
215 assert_eq!(request.headers().get(REPLICATION_HEADER), Some("true"));
216 }
217
218 #[test]
219 fn eureka_s_own_marker_is_recognised() {
220 assert!(is_replication(Some("true"), None));
221 assert!(is_replication(Some("TRUE"), None));
222 assert!(is_replication(None, Some("true")));
223 }
224
225 #[test]
228 fn an_ordinary_request_is_not_mistaken_for_a_replicated_one() {
229 assert!(!is_replication(None, None));
230 assert!(!is_replication(Some(""), None));
231 assert!(!is_replication(Some("false"), None));
232 assert!(!is_replication(Some("1"), None), "only `true` means true");
233 assert!(!is_replication(None, Some("false")));
234 }
235
236 #[test]
238 fn a_node_does_not_replicate_to_itself() {
239 let peers = peers(&["http://a:8761", "http://b:8761", "http://c:8761"])
240 .excluding("http://b:8761/");
241
242 assert_eq!(peers.addresses(), ["http://a:8761", "http://c:8761"]);
243 }
244
245 #[test]
246 fn each_change_goes_where_a_peer_expects_it() {
247 let peers = peers(&["http://b:8761"]);
248 let url_for = |change: &Change| peers.request("http://b:8761", change).url().to_string();
249 let method_for = |change: &Change| peers.request("http://b:8761", change).method();
250
251 let renew = Change::Renewed { service: "orders".into(), id: "10.0.0.1:8080".into() };
252 assert_eq!(method_for(&renew), Method::Put);
253 assert!(url_for(&renew).starts_with("http://b:8761/eureka/apps/ORDERS/10.0.0.1:8080?"));
254
255 let cancel = Change::Cancelled { service: "orders".into(), id: "10.0.0.1:8080".into() };
256 assert_eq!(method_for(&cancel), Method::Delete);
257
258 let status = Change::StatusChanged {
261 service: "orders".into(),
262 id: "10.0.0.1:8080".into(),
263 status: Status::OutOfService,
264 };
265 let url = url_for(&status);
266 assert!(url.contains("status?value=OUT_OF_SERVICE&isReplication=true"), "{url}");
267 assert_eq!(url.matches('?').count(), 1, "two query strings: {url}");
268 }
269
270 #[tokio::test]
271 async fn a_registration_reaches_every_peer() {
272 let http = Http::new().faking(Fake::new().fallback(FakeResponse::text("")));
273 let fake = std::sync::Arc::clone(http.fake().unwrap());
274 let peers = peers(&["http://b:8761", "http://c:8761"]).http(http);
275
276 let accepted = peers.send(&Change::Registered(Instance::new("orders", "h", 80))).await;
277
278 assert_eq!(accepted, 2);
279 fake.assert_sent("b:8761");
280 fake.assert_sent("c:8761");
281 }
282
283 #[tokio::test]
286 async fn a_dead_peer_does_not_stop_the_others() {
287 let http = Http::new().faking(
288 Fake::new()
289 .on("dead", FakeResponse::text("boom").status(500))
290 .fallback(FakeResponse::text("")),
291 );
292 let peers = peers(&["http://dead:8761", "http://alive:8761"]).http(http);
293
294 assert_eq!(peers.send(&Change::Registered(Instance::new("orders", "h", 80))).await, 1);
295 }
296
297 #[test]
298 fn a_node_with_no_peers_spawns_nothing() {
299 peers(&[]).spread(Change::Cancelled { service: "orders".into(), id: "x".into() });
302 }
303}