use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use rustlavel_client::{Client as Http, ClientResponse};
use rustlavel_core::{Error, Json, Result};
use rustlavel_http::{Method, Status};
use crate::eureka;
use crate::instance::{Instance, now};
use crate::resolve::Resolve;
pub const REFRESH: u64 = 30;
async fn ask(
http: &Http,
servers: &[String],
method: Method,
path: &str,
body: Option<Json>,
) -> Result<ClientResponse> {
let mut last: Option<Error> = None;
for server in servers {
let url = format!("{}{path}", server.trim_end_matches('/'));
let mut request = http.request(method, url);
if let Some(body) = body.clone() {
request = request.json(body);
}
match request.send().await {
Ok(response) if response.status.0 >= 500 => {
last = Some(Error::msg(format!("{server} answered HTTP {}", response.status)));
}
Ok(response) => return Ok(response),
Err(error) => last = Some(error),
}
}
Err(last.unwrap_or_else(|| Error::msg("no discovery server is configured")))
}
fn app_path(service: &str) -> String {
format!("/eureka/apps/{}", service.to_ascii_uppercase())
}
pub struct Registrar {
servers: Vec<String>,
instance: Instance,
http: Http,
}
impl Registrar {
pub fn new(servers: Vec<String>, instance: Instance) -> Registrar {
Registrar {
servers,
instance,
http: Http::new().timeout(Duration::from_secs(5)),
}
}
pub fn http(mut self, http: Http) -> Registrar {
self.http = http;
self
}
pub fn instance(&self) -> &Instance {
&self.instance
}
pub async fn register(&self) -> Result<()> {
let body = Json::object([("instance", eureka::instance_json(&self.instance))]);
ask(
&self.http,
&self.servers,
Method::Post,
&app_path(&self.instance.service),
Some(body),
)
.await?
.error_for_status()?;
Ok(())
}
pub async fn heartbeat(&self) -> Result<bool> {
let path = format!("{}/{}", app_path(&self.instance.service), self.instance.id);
let response = ask(&self.http, &self.servers, Method::Put, &path, None).await?;
if response.status == Status::NOT_FOUND {
return Ok(false);
}
response.error_for_status()?;
Ok(true)
}
pub async fn deregister(&self) -> Result<()> {
let path = format!("{}/{}", app_path(&self.instance.service), self.instance.id);
ask(&self.http, &self.servers, Method::Delete, &path, None).await?.error_for_status()?;
Ok(())
}
pub fn start(self: Arc<Self>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let interval = Duration::from_secs(self.instance.renew_interval);
if let Err(error) = self.register().await {
rustlavel_core::warn!("discovery: could not register: {error}");
}
loop {
tokio::time::sleep(interval).await;
match self.heartbeat().await {
Ok(true) => {}
Ok(false) => {
if let Err(error) = self.register().await {
rustlavel_core::warn!("discovery: could not re-register: {error}");
}
}
Err(error) => rustlavel_core::warn!("discovery: heartbeat failed: {error}"),
}
}
})
}
}
#[derive(Clone)]
struct Cached {
instances: Vec<Instance>,
at: u64,
}
pub struct Discovery {
servers: Vec<String>,
http: Http,
refresh: u64,
cache: Arc<RwLock<HashMap<String, Cached>>>,
}
impl Discovery {
pub fn new(servers: Vec<String>) -> Discovery {
Discovery {
servers,
http: Http::new().timeout(Duration::from_secs(5)),
refresh: REFRESH,
cache: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn http(mut self, http: Http) -> Discovery {
self.http = http;
self
}
pub fn refresh_every(mut self, seconds: u64) -> Discovery {
self.refresh = seconds;
self
}
fn cached(&self, service: &str) -> Option<Cached> {
self.cache.read().expect("the discovery cache lock is poisoned").get(service).cloned()
}
async fn fetch(&self, service: &str) -> Result<Vec<Instance>> {
let response =
ask(&self.http, &self.servers, Method::Get, &app_path(service), None).await?;
if response.status == Status::NOT_FOUND {
return Ok(Vec::new());
}
let body = response.error_for_status()?.json()?;
let Some(Json::Array(entries)) = body.get("application.instance") else {
return Ok(Vec::new());
};
Ok(entries
.iter()
.filter_map(eureka::read_instance)
.filter(|instance| instance.status.takes_traffic())
.collect())
}
pub async fn lookup(&self, service: &str) -> Result<Vec<Instance>> {
let service = service.to_ascii_uppercase();
let held = self.cached(&service);
if let Some(held) = &held
&& now().saturating_sub(held.at) < self.refresh
{
return Ok(held.instances.clone());
}
match self.fetch(&service).await {
Ok(instances) => {
self.cache
.write()
.expect("the discovery cache lock is poisoned")
.insert(service, Cached { instances: instances.clone(), at: now() });
Ok(instances)
}
Err(error) => match held {
Some(held) => {
rustlavel_core::warn!(
"discovery: {service} served from a stale cache: {error}"
);
Ok(held.instances)
}
None => Err(error),
},
}
}
}
impl Resolve for Discovery {
fn instances<'a>(
&'a self,
service: &'a str,
) -> std::pin::Pin<Box<dyn Future<Output = Result<Vec<Instance>>> + Send + 'a>> {
Box::pin(self.lookup(service))
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustlavel_client::{Fake, FakeResponse};
fn registry_with(instances: Vec<Instance>) -> Http {
let body = eureka::application_json("ORDERS", &instances);
Http::new().faking(Fake::new().on("/eureka/apps/ORDERS", FakeResponse::json(body)))
}
fn unreachable() -> Http {
Http::new().faking(Fake::new())
}
#[tokio::test]
async fn a_lookup_reads_the_registry() {
let discovery = Discovery::new(vec!["http://registry:8761".into()])
.http(registry_with(vec![Instance::new("orders", "10.0.0.1", 8080)]));
let found = discovery.lookup("orders").await.unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].url(), "http://10.0.0.1:8080");
}
#[tokio::test]
async fn a_service_nobody_registered_is_empty_rather_than_an_error() {
let http = Http::new()
.faking(Fake::new().fallback(FakeResponse::text("not found").status(404)));
let discovery = Discovery::new(vec!["http://registry:8761".into()]).http(http);
assert_eq!(discovery.lookup("nothing").await.unwrap().len(), 0);
}
#[tokio::test]
async fn the_registry_going_down_does_not_stop_the_lookups() {
let mut discovery = Discovery::new(vec!["http://registry:8761".into()])
.refresh_every(0)
.http(registry_with(vec![Instance::new("orders", "10.0.0.1", 8080)]));
assert_eq!(discovery.lookup("orders").await.unwrap().len(), 1);
discovery = discovery.http(unreachable());
let found = discovery.lookup("orders").await.expect("a stale answer, not a failure");
assert_eq!(found.len(), 1, "the cached list was thrown away when the registry went down");
}
#[tokio::test]
async fn an_unreachable_registry_with_nothing_cached_is_an_error() {
let discovery = Discovery::new(vec!["http://registry:8761".into()]).http(unreachable());
assert!(discovery.lookup("orders").await.is_err());
}
#[tokio::test]
async fn a_fresh_answer_is_reused_rather_than_asked_for_again() {
let http = registry_with(vec![Instance::new("orders", "10.0.0.1", 8080)]);
let fake = Arc::clone(http.fake().expect("a faking client"));
let discovery = Discovery::new(vec!["http://registry:8761".into()]).http(http);
discovery.lookup("orders").await.unwrap();
discovery.lookup("orders").await.unwrap();
fake.assert_count(1);
}
#[tokio::test]
async fn an_instance_that_is_not_up_is_not_returned() {
use crate::instance::Status as InstanceStatus;
let http = registry_with(vec![
Instance::new("orders", "10.0.0.1", 8080),
Instance::new("orders", "10.0.0.2", 8080).status(InstanceStatus::OutOfService),
]);
let discovery = Discovery::new(vec!["http://registry:8761".into()]).http(http);
let found = discovery.lookup("orders").await.unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].host, "10.0.0.1");
}
#[tokio::test]
async fn a_dead_registry_is_stepped_over() {
let http = Http::new().faking(
Fake::new()
.on("dead", FakeResponse::text("boom").status(503))
.on("alive", FakeResponse::json(eureka::application_json("ORDERS", &[]))),
);
let fake = Arc::clone(http.fake().unwrap());
let discovery =
Discovery::new(vec!["http://dead:8761".into(), "http://alive:8761".into()]).http(http);
assert!(discovery.lookup("orders").await.is_ok());
fake.assert_sent("alive");
}
#[tokio::test]
async fn registering_posts_the_instance_where_eureka_expects_it() {
let http = Http::new().faking(Fake::new().fallback(FakeResponse::text("")));
let fake = Arc::clone(http.fake().unwrap());
let registrar =
Registrar::new(vec!["http://registry:8761".into()], Instance::new("orders", "h", 80))
.http(http);
registrar.register().await.unwrap();
let sent = fake.recorded();
assert_eq!(sent[0].method, Method::Post);
assert!(sent[0].url.ends_with("/eureka/apps/ORDERS"), "{}", sent[0].url);
let body = sent[0].json().expect("a JSON body");
assert_eq!(body.get("instance.app").and_then(Json::as_str), Some("ORDERS"));
}
#[tokio::test]
async fn a_heartbeat_for_a_forgotten_instance_asks_for_a_re_registration() {
let forgotten = Http::new()
.faking(Fake::new().fallback(FakeResponse::text("unknown").status(404)));
let registrar =
Registrar::new(vec!["http://registry:8761".into()], Instance::new("orders", "h", 80))
.http(forgotten);
assert!(!registrar.heartbeat().await.unwrap(), "a forgotten instance was told it was fine");
}
#[tokio::test]
async fn deregistering_names_the_instance() {
let http = Http::new().faking(Fake::new().fallback(FakeResponse::text("")));
let fake = Arc::clone(http.fake().unwrap());
let registrar = Registrar::new(
vec!["http://registry:8761".into()],
Instance::new("orders", "10.0.0.1", 8080),
)
.http(http);
registrar.deregister().await.unwrap();
let sent = fake.recorded();
assert_eq!(sent[0].method, Method::Delete);
assert!(sent[0].url.ends_with("/eureka/apps/ORDERS/10.0.0.1:8080"), "{}", sent[0].url);
}
}