use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use plecto_control::{Control, UpstreamGroup};
type GroupCache = Arc<tokio::sync::Mutex<HashMap<String, Vec<String>>>>;
#[derive(Default)]
struct RefreshState {
last_run: HashMap<String, Instant>,
last_good: HashMap<String, GroupCache>,
}
pub(crate) async fn serve_dns_refresh(
control: Arc<Control>,
mut drain: tokio::sync::watch::Receiver<bool>,
) {
let mut state = RefreshState::default();
loop {
let groups = control.upstream_groups();
let period = refresh_tick(&groups, &mut state, &lookup).await;
tokio::select! {
_ = crate::listener::drained(&mut drain) => return,
_ = tokio::time::sleep(period.max(Duration::from_millis(20))) => {}
}
}
}
async fn refresh_tick<F, Fut>(
groups: &[Arc<UpstreamGroup>],
state: &mut RefreshState,
resolver: &F,
) -> Duration
where
F: Fn(String) -> Fut + Clone + Send + Sync + 'static,
Fut: Future<Output = std::io::Result<Vec<SocketAddr>>> + Send,
{
let now = Instant::now();
let mut live: HashSet<String> = HashSet::new();
let mut period = Duration::from_secs(5);
for g in groups {
let Some(interval) = g.resolve_interval() else {
continue;
};
period = period.min(interval);
live.insert(g.name.clone());
let due = state
.last_run
.get(&g.name)
.is_none_or(|t| now.duration_since(*t) >= interval);
if due {
state.last_run.insert(g.name.clone(), now);
let cache = state.last_good.entry(g.name.clone()).or_default().clone();
let group = g.clone();
let resolver = resolver.clone();
tokio::spawn(async move {
let Ok(mut cache) = cache.try_lock() else {
return;
};
refresh_group(&group, &mut cache, resolver).await;
});
}
}
state.last_run.retain(|k, _| live.contains(k));
state.last_good.retain(|k, _| live.contains(k));
period
}
async fn lookup(addr: String) -> std::io::Result<Vec<SocketAddr>> {
Ok(tokio::net::lookup_host(addr.as_str()).await?.collect())
}
async fn refresh_group<F, Fut>(
group: &Arc<UpstreamGroup>,
last_good: &mut HashMap<String, Vec<String>>,
resolver: F,
) -> bool
where
F: Fn(String) -> Fut,
Fut: Future<Output = std::io::Result<Vec<SocketAddr>>>,
{
last_good.retain(|addr, _| group.configured_addresses().iter().any(|(a, _)| a == addr));
let mut resolved: Vec<(String, u32)> = Vec::new();
for (addr, weight) in group.configured_addresses() {
if addr.parse::<SocketAddr>().is_ok() {
resolved.push((addr.clone(), *weight));
continue;
}
match resolver(addr.clone()).await {
Ok(addrs) if !addrs.is_empty() => {
let mut expansion: Vec<String> =
addrs.into_iter().map(|sa| sa.to_string()).collect();
expansion.sort();
expansion.dedup();
resolved.extend(expansion.iter().map(|a| (a.clone(), *weight)));
last_good.insert(addr.clone(), expansion);
}
_ => match last_good.get(addr) {
Some(last) => resolved.extend(last.iter().map(|a| (a.clone(), *weight))),
None => resolved.push((addr.clone(), *weight)),
},
}
}
group.update_endpoints(&resolved)
}
#[cfg(test)]
mod tests {
use super::*;
use plecto_control::{Manifest, UpstreamRegistry};
fn resolving_group_named(name: &str, addresses: &str) -> Arc<UpstreamGroup> {
let toml = format!(
r#"
[[upstream]]
name = "{name}"
addresses = [{addresses}]
resolve_interval_ms = 100
[upstream.health]
path = "/healthz"
healthy_threshold = 1
"#
);
let manifest = Manifest::from_toml(&toml).unwrap();
let registry = UpstreamRegistry::new();
registry
.reconcile(&manifest.upstreams, std::path::Path::new("."))
.unwrap();
registry.group(name).unwrap()
}
fn resolving_group(addresses: &str) -> Arc<UpstreamGroup> {
resolving_group_named("app", addresses)
}
fn addrs(list: &[&str]) -> std::io::Result<Vec<SocketAddr>> {
Ok(list.iter().map(|a| a.parse().unwrap()).collect())
}
#[tokio::test]
async fn one_blocked_resolver_does_not_stall_another_groups_refresh() {
let ga = resolving_group_named("a", "\"a.internal:80\"");
let gb = resolving_group_named("b", "\"b.internal:80\"");
let barrier = Arc::new(tokio::sync::Barrier::new(2));
let resolver = {
let barrier = barrier.clone();
move |_addr: String| {
let barrier = barrier.clone();
async move {
barrier.wait().await;
addrs(&["10.0.0.9:80"])
}
}
};
let groups = vec![ga.clone(), gb.clone()];
let mut state = RefreshState::default();
tokio::time::timeout(Duration::from_secs(5), async {
refresh_tick(&groups, &mut state, &resolver).await;
loop {
let done =
|g: &Arc<UpstreamGroup>| g.endpoints().instances[0].address() == "10.0.0.9:80";
if done(&ga) && done(&gb) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("both groups must refresh concurrently, not serially");
}
#[tokio::test]
async fn expansion_swap_preserves_surviving_endpoint_health() {
let group = resolving_group("\"app.internal:80\"");
let mut cache = HashMap::new();
let swapped = refresh_group(&group, &mut cache, |_| async {
addrs(&["10.0.0.1:80", "10.0.0.2:80"])
})
.await;
assert!(swapped, "first resolution swaps hostname → IP endpoints");
let ep = group.endpoints();
assert_eq!(ep.instances.len(), 2);
ep.instances[0].record_probe_success();
let swapped = refresh_group(&group, &mut cache, |_| async {
addrs(&["10.0.0.1:80", "10.0.0.3:80"])
})
.await;
assert!(swapped, "a changed record set swaps again");
let ep = group.endpoints();
assert_eq!(ep.instances.len(), 2);
assert!(
ep.instances[0].is_healthy(),
"the surviving 10.0.0.1 keeps its health across the swap"
);
assert_eq!(ep.instances[1].address(), "10.0.0.3:80");
assert!(
!ep.instances[1].is_healthy(),
"the new record starts pessimistic (ADR 000017)"
);
}
#[tokio::test]
async fn unchanged_resolution_does_not_swap() {
let group = resolving_group("\"app.internal:80\"");
let mut cache = HashMap::new();
refresh_group(&group, &mut cache, |_| async { addrs(&["10.0.0.1:80"]) }).await;
let before = group.endpoints();
let swapped =
refresh_group(&group, &mut cache, |_| async { addrs(&["10.0.0.1:80"]) }).await;
assert!(!swapped, "an identical answer must not swap the set");
assert!(
Arc::ptr_eq(&before, &group.endpoints()),
"the endpoint set Arc is untouched on an unchanged answer"
);
}
#[tokio::test]
async fn failed_resolution_keeps_the_last_known_good_set() {
let group = resolving_group("\"app.internal:80\"");
let mut cache = HashMap::new();
refresh_group(&group, &mut cache, |_| async { addrs(&["10.0.0.1:80"]) }).await;
group.endpoints().instances[0].record_probe_success();
let swapped = refresh_group(&group, &mut cache, |_| async {
Err(std::io::Error::other("dns down"))
})
.await;
assert!(!swapped, "a failed resolution must not change the set");
let ep = group.endpoints();
assert_eq!(ep.instances[0].address(), "10.0.0.1:80");
assert!(ep.instances[0].is_healthy(), "and health is untouched");
}
#[tokio::test]
async fn never_resolved_hostname_stays_as_its_own_endpoint() {
let group = resolving_group("\"app.internal:80\"");
let mut cache = HashMap::new();
let swapped = refresh_group(&group, &mut cache, |_| async {
Err(std::io::Error::other("dns down"))
})
.await;
assert!(!swapped);
assert_eq!(group.endpoints().instances[0].address(), "app.internal:80");
}
#[tokio::test]
async fn ip_literals_never_round_trip_through_the_resolver() {
let group = resolving_group("\"10.9.9.9:80\", \"app.internal:80\"");
let mut cache = HashMap::new();
refresh_group(&group, &mut cache, |addr| async move {
assert_eq!(
addr, "app.internal:80",
"only the hostname reaches the resolver"
);
addrs(&["10.0.0.1:80"])
})
.await;
let ep = group.endpoints();
assert_eq!(
ep.instances
.iter()
.map(|i| i.address().to_string())
.collect::<Vec<_>>(),
vec!["10.9.9.9:80".to_string(), "10.0.0.1:80".to_string()],
"the IP literal passes through; the hostname expands"
);
}
}