use std::time::Duration;
use rustlavel_client::{Client as Http, RequestBuilder};
use rustlavel_core::Json;
use rustlavel_http::Method;
use crate::eureka;
use crate::instance::{Instance, Status};
pub const REPLICATION_QUERY: &str = "isReplication=true";
pub const REPLICATION_HEADER: &str = "x-rustlavel-replication";
#[derive(Debug, Clone)]
pub enum Change {
Registered(Instance),
Renewed { service: String, id: String },
Cancelled { service: String, id: String },
StatusChanged { service: String, id: String, status: Status },
}
impl Change {
fn method(&self) -> Method {
match self {
Change::Registered(_) => Method::Post,
Change::Renewed { .. } | Change::StatusChanged { .. } => Method::Put,
Change::Cancelled { .. } => Method::Delete,
}
}
fn path(&self) -> String {
let app = |service: &str| service.to_ascii_uppercase();
match self {
Change::Registered(instance) => format!("/eureka/apps/{}", app(&instance.service)),
Change::Renewed { service, id } | Change::Cancelled { service, id } => {
format!("/eureka/apps/{}/{id}", app(service))
}
Change::StatusChanged { service, id, status } => {
format!("/eureka/apps/{}/{id}/status?value={}", app(service), status.as_str())
}
}
}
fn body(&self) -> Option<Json> {
match self {
Change::Registered(instance) => {
Some(Json::object([("instance", eureka::instance_json(instance))]))
}
_ => None,
}
}
}
pub fn is_replication(marker: Option<&str>, header: Option<&str>) -> bool {
[marker, header]
.into_iter()
.flatten()
.any(|value| value.trim().eq_ignore_ascii_case("true"))
}
#[derive(Clone)]
pub struct Peers {
addresses: Vec<String>,
http: Http,
}
impl Peers {
pub fn new(addresses: Vec<String>) -> Peers {
Peers {
addresses,
http: Http::new().timeout(Duration::from_secs(5)),
}
}
pub fn excluding(mut self, own: &str) -> Peers {
let own = own.trim_end_matches('/');
self.addresses.retain(|address| address.trim_end_matches('/') != own);
self
}
pub fn http(mut self, http: Http) -> Peers {
self.http = http;
self
}
pub fn addresses(&self) -> &[String] {
&self.addresses
}
fn request(&self, peer: &str, change: &Change) -> RequestBuilder {
let path = change.path();
let separator = if path.contains('?') { '&' } else { '?' };
let url = format!("{}{path}{separator}{REPLICATION_QUERY}", peer.trim_end_matches('/'));
let mut request =
self.http.request(change.method(), url).header(REPLICATION_HEADER, "true");
if let Some(body) = change.body() {
request = request.json(body);
}
request
}
pub async fn send(&self, change: &Change) -> usize {
let mut accepted = 0;
for peer in &self.addresses {
match self.request(peer, change).send().await {
Ok(response) if response.is_success() => accepted += 1,
Ok(response) => rustlavel_core::warn!(
"discovery: {peer} refused a replicated change: HTTP {}",
response.status
),
Err(error) => {
rustlavel_core::warn!("discovery: {peer} is unreachable: {error}")
}
}
}
accepted
}
pub fn spread(&self, change: Change) {
if self.addresses.is_empty() {
return;
}
let peers = self.clone();
tokio::spawn(async move {
peers.send(&change).await;
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustlavel_client::{Fake, FakeResponse};
fn peers(addresses: &[&str]) -> Peers {
Peers::new(addresses.iter().map(|a| a.to_string()).collect())
}
#[test]
fn a_forwarded_change_is_marked_both_ways() {
let request = peers(&["http://b:8761"])
.request("http://b:8761", &Change::Registered(Instance::new("orders", "h", 80)));
assert!(request.url().contains(REPLICATION_QUERY), "{}", request.url());
assert_eq!(request.headers().get(REPLICATION_HEADER), Some("true"));
}
#[test]
fn eureka_s_own_marker_is_recognised() {
assert!(is_replication(Some("true"), None));
assert!(is_replication(Some("TRUE"), None));
assert!(is_replication(None, Some("true")));
}
#[test]
fn an_ordinary_request_is_not_mistaken_for_a_replicated_one() {
assert!(!is_replication(None, None));
assert!(!is_replication(Some(""), None));
assert!(!is_replication(Some("false"), None));
assert!(!is_replication(Some("1"), None), "only `true` means true");
assert!(!is_replication(None, Some("false")));
}
#[test]
fn a_node_does_not_replicate_to_itself() {
let peers = peers(&["http://a:8761", "http://b:8761", "http://c:8761"])
.excluding("http://b:8761/");
assert_eq!(peers.addresses(), ["http://a:8761", "http://c:8761"]);
}
#[test]
fn each_change_goes_where_a_peer_expects_it() {
let peers = peers(&["http://b:8761"]);
let url_for = |change: &Change| peers.request("http://b:8761", change).url().to_string();
let method_for = |change: &Change| peers.request("http://b:8761", change).method();
let renew = Change::Renewed { service: "orders".into(), id: "10.0.0.1:8080".into() };
assert_eq!(method_for(&renew), Method::Put);
assert!(url_for(&renew).starts_with("http://b:8761/eureka/apps/ORDERS/10.0.0.1:8080?"));
let cancel = Change::Cancelled { service: "orders".into(), id: "10.0.0.1:8080".into() };
assert_eq!(method_for(&cancel), Method::Delete);
let status = Change::StatusChanged {
service: "orders".into(),
id: "10.0.0.1:8080".into(),
status: Status::OutOfService,
};
let url = url_for(&status);
assert!(url.contains("status?value=OUT_OF_SERVICE&isReplication=true"), "{url}");
assert_eq!(url.matches('?').count(), 1, "two query strings: {url}");
}
#[tokio::test]
async fn a_registration_reaches_every_peer() {
let http = Http::new().faking(Fake::new().fallback(FakeResponse::text("")));
let fake = std::sync::Arc::clone(http.fake().unwrap());
let peers = peers(&["http://b:8761", "http://c:8761"]).http(http);
let accepted = peers.send(&Change::Registered(Instance::new("orders", "h", 80))).await;
assert_eq!(accepted, 2);
fake.assert_sent("b:8761");
fake.assert_sent("c:8761");
}
#[tokio::test]
async fn a_dead_peer_does_not_stop_the_others() {
let http = Http::new().faking(
Fake::new()
.on("dead", FakeResponse::text("boom").status(500))
.fallback(FakeResponse::text("")),
);
let peers = peers(&["http://dead:8761", "http://alive:8761"]).http(http);
assert_eq!(peers.send(&Change::Registered(Instance::new("orders", "h", 80))).await, 1);
}
#[test]
fn a_node_with_no_peers_spawns_nothing() {
peers(&[]).spread(Change::Cancelled { service: "orders".into(), id: "x".into() });
}
}