use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
use prometheus::{register_int_counter_vec, register_int_gauge_vec, IntCounterVec, IntGaugeVec};
use tracing::{debug, info, warn};
use zentinel_common::{Registry, ScopedRegistry};
use super::UpstreamPool;
const TICK: Duration = Duration::from_secs(1);
const IDLE_TICK: Duration = Duration::from_secs(30);
const REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
static DISCOVERY_REFRESHES: LazyLock<Option<IntCounterVec>> = LazyLock::new(|| {
register_int_counter_vec!(
"zentinel_upstream_discovery_refreshes_total",
"Service discovery refreshes that changed an upstream's target set",
&["upstream"]
)
.ok()
});
static DISCOVERY_TARGETS: LazyLock<Option<IntGaugeVec>> = LazyLock::new(|| {
register_int_gauge_vec!(
"zentinel_upstream_discovery_targets",
"Targets currently resolved for an upstream backed by service discovery",
&["upstream"]
)
.ok()
});
pub(crate) fn spawn(global: Registry<UpstreamPool>, scoped: ScopedRegistry<UpstreamPool>) {
tokio::spawn(async move {
run(global, scoped).await;
});
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum Source {
Global,
Scoped,
}
async fn run(global: Registry<UpstreamPool>, scoped: ScopedRegistry<UpstreamPool>) {
let mut due: HashMap<(Source, String), Instant> = HashMap::new();
loop {
let now = Instant::now();
let mut live: Vec<(Source, String, Arc<UpstreamPool>, Duration)> = Vec::new();
for (id, pool) in global.snapshot().await {
if let Some(interval) = pool.discovery_refresh_interval() {
live.push((Source::Global, id, pool, interval));
}
}
for (id, pool) in scoped.snapshot().await {
if let Some(interval) = pool.discovery_refresh_interval() {
live.push((Source::Scoped, id, pool, interval));
}
}
if live.is_empty() {
due.clear();
tokio::time::sleep(IDLE_TICK).await;
continue;
}
let present: std::collections::HashSet<(Source, String)> =
live.iter().map(|(s, id, _, _)| (*s, id.clone())).collect();
due.retain(|key, _| present.contains(key));
for (source, id, pool, interval) in live {
let key = (source, id.clone());
let deadline = *due.entry(key.clone()).or_insert(now + interval);
if now < deadline {
continue;
}
due.insert(key, now + interval);
let global = global.clone();
let scoped = scoped.clone();
tokio::spawn(async move {
refresh_one(source, id, pool, global, scoped).await;
});
}
tokio::time::sleep(TICK).await;
}
}
async fn refresh_one(
source: Source,
id: String,
pool: Arc<UpstreamPool>,
global: Registry<UpstreamPool>,
scoped: ScopedRegistry<UpstreamPool>,
) {
let refreshed = match tokio::time::timeout(REFRESH_TIMEOUT, pool.refreshed()).await {
Ok(result) => result,
Err(_) => {
warn!(
upstream_id = %id,
timeout_secs = REFRESH_TIMEOUT.as_secs(),
"Service discovery refresh timed out; keeping current targets"
);
return;
}
};
let Some(new_pool) = refreshed else {
debug!(upstream_id = %id, "Service discovery refresh: no change");
return;
};
let target_count = new_pool.target_count();
let new_pool = Arc::new(new_pool);
let installed = match source {
Source::Global => global.insert(id.clone(), new_pool).await.is_some(),
Source::Scoped => scoped.replace_item(&id, new_pool).await.is_some(),
};
if !installed {
debug!(
upstream_id = %id,
"Upstream disappeared during discovery refresh; discarding result"
);
return;
}
if let Some(counter) = DISCOVERY_REFRESHES.as_ref() {
counter.with_label_values(&[&id]).inc();
}
if let Some(gauge) = DISCOVERY_TARGETS.as_ref() {
gauge.with_label_values(&[&id]).set(target_count as i64);
}
info!(
upstream_id = %id,
target_count,
"Installed refreshed upstream pool"
);
}