dwara_core/dataplane/discovery.rs
1//! DNS-based dynamic upstream discovery (DW-042, feature analysis 4.4).
2//!
3//! When an upstream configures `dns_discovery`, a background
4//! [`DiscoveryTasks`] resolves the hostname via DNS (A or SRV records),
5//! updates the upstream's endpoint set in the [`UpstreamLb`] live, and
6//! re-resolves when the record TTL expires (or at `refresh_interval_s`,
7//! whichever comes first). Endpoint scale-up/down is reflected without a
8//! restart — new connections use the updated set; existing connections
9//! stay alive until they close.
10//!
11//! # Resolution
12//!
13//! [`DnsResolver`] wraps a `hickory_resolver::TokioAsyncResolver`. A
14//! records are resolved to `(IpAddr, ttl)` pairs; SRV records to
15//! `(IpAddr, port, ttl)` triples (the SRV target is resolved to an IP by
16//! the resolver's recursive lookup). The resolver is configured with the
17//! SYSTEM resolver's name servers when `/etc/resolv.conf` is readable
18//! (so container-internal names resolve), and the public Google
19//! resolvers otherwise.
20//!
21//! # Refresh cycle
22//!
23//! Each cycle: resolve, update the endpoint set, sleep
24//! `min(refresh_interval_s, record_ttl)`, repeat. On DNS failure:
25//! `fail_open: true` keeps the last resolved set; `fail_open: false`
26//! clears endpoints (the upstream answers 503 until DNS recovers). The
27//! `min_endpoints` floor prevents shrinking below a configured minimum —
28//! if a resolution yields fewer endpoints, the previous set is kept.
29//!
30//! # Task lifecycle
31//!
32//! [`DiscoveryTasks`] owns one [`tokio::task::JoinSet`] for every
33//! discovery task. The operator (dwara-bin) calls
34//! [`DiscoveryTasks::respawn`] after every snapshot swap (startup and
35//! each reload): all previous tasks are aborted and new ones spawned for
36//! the new generation's upstreams. Dropping [`DiscoveryTasks`] (or
37//! calling [`DiscoveryTasks::abort_all`]) aborts every task — the
38//! graceful-shutdown path. This mirrors [`crate::dataplane::active::ActiveProbes`] (DW-013), the
39//! closest precedent for a long-running dataplane task.
40//!
41//! Consul watch and Kubernetes EndpointSlice watch are DEFERRED to a
42//! future milestone — DNS is the first discovery source.
43
44use std::collections::HashMap;
45use std::net::{IpAddr, SocketAddr};
46use std::sync::Arc;
47use std::time::{Duration, Instant};
48
49use hickory_resolver::config::{NameServerConfig, ResolverConfig};
50use hickory_resolver::lookup::Lookup;
51use hickory_resolver::net::runtime::TokioRuntimeProvider;
52use hickory_resolver::TokioResolver;
53use tokio::sync::Mutex;
54use tokio::task::JoinSet;
55
56use crate::config::{DnsDiscovery, Endpoint};
57use crate::dataplane::balance::UpstreamLb;
58use crate::dataplane::upstream::UpstreamHandle;
59use crate::observability::Observability;
60
61/// Default DNS name servers when none are explicitly configured AND the
62/// system resolver config is unreadable: the public Google resolvers.
63/// When `/etc/resolv.conf` is present and parses (the container case —
64/// Docker and Kubernetes write their embedded resolver there), its name
65/// servers are PREFERRED (see [`system_nameservers`]) so cluster-local
66/// names (`my-svc`, `my-svc.ns.svc.cluster.local`) resolve: public
67/// resolvers cannot answer those names, and an explicit-public-only
68/// default would make every service-name endpoint unresolvable in a
69/// container deployment.
70const DEFAULT_NAMESERVERS: &[&str] = &["8.8.8.8:53", "8.8.4.4:53"];
71
72/// Name servers from the system resolver configuration: the
73/// `nameserver` lines of `/etc/resolv.conf` (the file Docker and
74/// Kubernetes write their embedded resolver into). Returns None when
75/// the file is absent, unreadable, or carries no `nameserver` lines —
76/// the caller then falls back to [`DEFAULT_NAMESERVERS`]. Only the
77/// server addresses are taken (port 53; DNS options and search domains
78/// are ignored — cluster-local names resolve as-is against the embedded
79/// resolver, which sets `ndots:0`). Hand-parsed rather than via
80/// hickory's `system-config` feature: that feature drags in
81/// platform-specific dependencies (ipconfig, jni, system-configuration)
82/// for what is, on every container platform, a three-line file.
83fn system_nameservers() -> Option<Vec<String>> {
84 let text = std::fs::read_to_string("/etc/resolv.conf").ok()?;
85 let servers: Vec<String> = text
86 .lines()
87 .map(str::trim)
88 .filter_map(|line| line.strip_prefix("nameserver"))
89 .map(str::trim)
90 .filter(|ip| !ip.is_empty())
91 // `ip:port` socket-addr form: IPv6 literals need brackets.
92 .map(|ip| {
93 if ip.contains(':') {
94 format!("[{ip}]:53")
95 } else {
96 format!("{ip}:53")
97 }
98 })
99 // Only well-formed addresses survive; a malformed line must not
100 // panic the resolver constructor downstream.
101 .filter(|addr| addr.parse::<SocketAddr>().is_ok())
102 .collect();
103 (!servers.is_empty()).then_some(servers)
104}
105
106/// A TTL-aware DNS resolver wrapping `hickory_resolver::TokioResolver`.
107///
108/// Configured with explicit name servers (the system resolver is not
109/// used by default). `resolve_a` returns `(IpAddr, ttl)` pairs; `resolve_srv`
110/// returns `(IpAddr, port, ttl)` triples.
111pub struct DnsResolver {
112 inner: TokioResolver,
113}
114
115impl DnsResolver {
116 /// Build a resolver that queries the given name server addresses
117 /// (e.g. `["127.0.0.1:5353"]`). An empty list falls back to the
118 /// SYSTEM resolver configuration when one is readable (container
119 /// deployments: Docker/Kubernetes embedded DNS), and to the default
120 /// public resolvers otherwise (see [`system_nameservers`]).
121 pub fn new(name_servers: &[String]) -> Self {
122 let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
123 let servers: Vec<String> = if name_servers.is_empty() {
124 system_nameservers()
125 .unwrap_or_else(|| DEFAULT_NAMESERVERS.iter().map(|s| s.to_string()).collect())
126 } else {
127 name_servers.to_vec()
128 };
129 for addr in &servers {
130 let socket_addr: std::net::SocketAddr = addr
131 .parse()
132 .unwrap_or_else(|_| panic!("invalid DNS name server address: {addr}"));
133 // hickory 0.26: NameServerConfig takes an IpAddr and a vec of
134 // ConnectionConfig. The port is on ConnectionConfig, not on
135 // NameServerConfig (it defaulted to 53 in ::udp()). Set it
136 // explicitly so non-53 ports (e.g. mock DNS servers in tests)
137 // work. UDP-only is sufficient for discovery; the resolver
138 // falls back to TCP for truncated responses automatically.
139 let mut conn = hickory_resolver::config::ConnectionConfig::udp();
140 conn.port = socket_addr.port();
141 config.add_name_server(NameServerConfig::new(socket_addr.ip(), false, vec![conn]));
142 }
143 // Disable the resolver's own cache so the discovery task controls
144 // refresh timing via TTL. The resolver's LRU cache would serve
145 // stale records within the TTL window, which is fine for
146 // correctness but makes the refresh cycle's timing less
147 // predictable in tests. With caching disabled, every lookup hits
148 // the name server, and the discovery task's sleep governs the
149 // cadence.
150 let mut builder =
151 TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
152 builder.options_mut().cache_size = 0;
153 let inner = builder
154 .build()
155 .expect("failed to build TokioResolver (invalid name server config)");
156 Self { inner }
157 }
158
159 /// Resolve A records for `hostname`, returning `(IpAddr, ttl)` pairs.
160 /// The TTL is the minimum TTL across all returned records (the
161 /// earliest-expiring record governs the refresh interval). Returns an
162 /// empty vec if the hostname resolves to no A records.
163 pub async fn resolve_a(&self, hostname: &str) -> Result<Vec<(IpAddr, u32)>, String> {
164 let lookup: Lookup = self
165 .inner
166 .ipv4_lookup(hostname)
167 .await
168 .map_err(|e| format!("DNS A lookup for '{hostname}' failed: {e}"))?;
169 if lookup.answers().is_empty() {
170 return Ok(Vec::new());
171 }
172 let ttl = lookup.answers().iter().map(|r| r.ttl).min().unwrap_or(60);
173 let addrs: Vec<(IpAddr, u32)> = lookup
174 .answers()
175 .iter()
176 .filter_map(|r| match r.data {
177 hickory_resolver::proto::rr::RData::A(a) => Some((IpAddr::V4(a.0), ttl)),
178 _ => None,
179 })
180 .collect();
181 Ok(addrs)
182 }
183
184 /// Resolve both A and AAAA records for `hostname` (dual-stack), returning
185 /// `(IpAddr, ttl)` pairs. This is the dial-path resolution primitive:
186 /// `lookup_ip` short-circuits IP-literal inputs (no DNS), consults the
187 /// system hosts file (so `localhost` and any `/etc/hosts` entry resolves
188 /// without a network round-trip), and falls back to the configured name
189 /// servers for real hostnames. The TTL is the minimum across all
190 /// returned records (the earliest-expiring record governs caching).
191 /// Returns an empty vec if the hostname resolves to no addresses.
192 pub async fn resolve_ip(&self, hostname: &str) -> Result<Vec<(IpAddr, u32)>, String> {
193 let lookup = self
194 .inner
195 .lookup_ip(hostname)
196 .await
197 .map_err(|e| format!("DNS lookup for '{hostname}' failed: {e}"))?;
198 let answers = lookup.as_lookup().answers();
199 if answers.is_empty() {
200 return Ok(Vec::new());
201 }
202 let ttl = answers.iter().map(|r| r.ttl).min().unwrap_or(60);
203 let addrs: Vec<(IpAddr, u32)> = lookup.iter().map(|ip| (ip, ttl)).collect();
204 Ok(addrs)
205 }
206
207 /// Resolve SRV records for `hostname`, returning
208 /// `(IpAddr, port, ttl)` triples. The resolver recursively resolves
209 /// each SRV target to an IP. The TTL is the minimum TTL across all
210 /// returned records. Returns an empty vec if the hostname resolves
211 /// to no SRV records.
212 pub async fn resolve_srv(&self, hostname: &str) -> Result<Vec<(IpAddr, u16, u32)>, String> {
213 let lookup: Lookup = self
214 .inner
215 .srv_lookup(hostname)
216 .await
217 .map_err(|e| format!("DNS SRV lookup for '{hostname}' failed: {e}"))?;
218 if lookup.answers().is_empty() {
219 return Ok(Vec::new());
220 }
221 let ttl = lookup.answers().iter().map(|r| r.ttl).min().unwrap_or(60);
222 // Pair each SRV record's port with the resolved IPs. For each
223 // SRV record, resolve the target hostname to IPv4 addresses via
224 // a separate A lookup so each (IP, port) pair is precise.
225 let mut endpoints = Vec::new();
226 let mut seen: std::collections::HashSet<(IpAddr, u16)> = std::collections::HashSet::new();
227 for record in lookup.answers() {
228 let srv = match &record.data {
229 hickory_resolver::proto::rr::RData::SRV(srv) => srv,
230 _ => continue,
231 };
232 let port = srv.port;
233 let target = srv.target.clone();
234 if let Ok(target_lookup) = self.inner.ipv4_lookup(target).await {
235 for record in target_lookup.answers() {
236 if let hickory_resolver::proto::rr::RData::A(a) = record.data {
237 let ip = IpAddr::V4(a.0);
238 if seen.insert((ip, port)) {
239 endpoints.push((ip, port, ttl));
240 }
241 }
242 }
243 }
244 }
245 Ok(endpoints)
246 }
247}
248
249/// Default cap on a cached positive lookup's effective TTL. Records carry
250/// their own TTL (and `/etc/hosts` entries carry a very large TTL); this
251/// caps the cache lifetime so a stale-but-cached address set does not
252/// linger past operator expectations on the hot dial path.
253const DEFAULT_POSITIVE_TTL_CAP: Duration = Duration::from_secs(300);
254/// Default TTL for cached negative (failed) lookups. Short, to suppress
255/// retry storms against a failing resolver while letting a flapping name
256/// server recover quickly.
257const DEFAULT_NEGATIVE_TTL: Duration = Duration::from_secs(5);
258/// Default max cached hostnames. Bounds memory; the dial path's working
259/// set is the set of distinct upstream hostnames, which is small, but the
260/// bound prevents unbounded growth under adversarial Host headers.
261const DEFAULT_MAX_ENTRIES: usize = 4096;
262/// Floor for a cached positive TTL so a zero-TTL record is still cached
263/// briefly (avoids re-querying a name server that hands out TTL 0).
264const POSITIVE_TTL_FLOOR: Duration = Duration::from_secs(1);
265
266/// One cached DNS entry: either a positive resolution (address set +
267/// expiry) or a negative one (failure + expiry). Negative entries cache
268/// the FACT of failure, not the error text, so the cached miss returns a
269/// stable `AddrNotAvailable` kind.
270enum CacheEntry {
271 Positive {
272 addrs: Vec<IpAddr>,
273 expires_at: Instant,
274 },
275 Negative {
276 expires_at: Instant,
277 },
278}
279
280/// A TTL-cached async DNS resolver for the pooled dial path (PERF-05,
281/// #170).
282///
283/// Wraps [`DnsResolver`] (a hickory `TokioResolver`) with two caches
284/// shared across every upstream in a registry:
285///
286/// - **Positive cache**: a successful lookup is reused until the record
287/// TTL expires (capped at `positive_ttl_cap`), so repeated dials to the
288/// same host skip both the blocking `getaddrinfo` path and the network
289/// round-trip. The cap bounds `/etc/hosts` entries (which carry a
290/// near-infinite TTL) and operator-facing staleness.
291/// - **Negative cache**: a failed lookup is cached for `negative_ttl`
292/// (default 5 s) so a transient resolver failure or NXDOMAIN does not
293/// trigger a retry storm on the hottest code path; the cached miss
294/// returns `AddrNotAvailable` until it expires, then re-resolves.
295///
296/// IP-literal hosts skip DNS and the cache entirely (the same
297/// short-circuit `getaddrinfo` takes). The cache is `Send + Sync` (one
298/// `tokio::sync::Mutex` over a `HashMap`); the critical section holds
299/// only the map read/insert, never the resolver future.
300pub struct DnsCache {
301 resolver: DnsResolver,
302 positive_ttl_cap: Duration,
303 negative_ttl: Duration,
304 max_entries: usize,
305 entries: Mutex<HashMap<String, CacheEntry>>,
306}
307
308impl Default for DnsCache {
309 /// Default cache: the system resolver's name servers when readable
310 /// (else the public resolvers) + the system hosts file, default
311 /// TTLs (positive cap 300 s, negative 5 s), 4096-entry bound.
312 fn default() -> Self {
313 Self::new(&[])
314 }
315}
316
317impl DnsCache {
318 /// Build a cache backed by the given name servers (empty = the
319 /// resolver default: system config when readable, else the public
320 /// resolvers; see [`DnsResolver::new`]) with the
321 /// default TTLs and entry cap.
322 pub fn new(name_servers: &[String]) -> Self {
323 Self::with_ttls(
324 name_servers,
325 DEFAULT_POSITIVE_TTL_CAP,
326 DEFAULT_NEGATIVE_TTL,
327 DEFAULT_MAX_ENTRIES,
328 )
329 }
330
331 /// Build a cache with explicit TTL/size knobs. Public so the
332 /// dial-path cache contract (TTL expiry, negative caching, memory
333 /// bound) can be pinned deterministically in tests without waiting
334 /// out the default 5 s negative TTL.
335 pub fn with_ttls(
336 name_servers: &[String],
337 positive_ttl_cap: Duration,
338 negative_ttl: Duration,
339 max_entries: usize,
340 ) -> Self {
341 Self {
342 resolver: DnsResolver::new(name_servers),
343 positive_ttl_cap,
344 negative_ttl,
345 max_entries,
346 entries: Mutex::new(HashMap::new()),
347 }
348 }
349
350 /// Resolve `host` to a list of `SocketAddr`s paired with `port`,
351 /// consulting the cache first. IP-literal hosts skip DNS and the
352 /// cache. Hostname hits return the cached address set (positive) or
353 /// a cached `AddrNotAvailable` (negative, within `negative_ttl`);
354 /// misses resolve via hickory and populate the cache.
355 pub async fn resolve(&self, host: &str, port: u16) -> std::io::Result<Vec<SocketAddr>> {
356 // IP-literal short-circuit: no DNS, no cache entry. This is the
357 // same path `getaddrinfo`/`lookup_host` took for literals, so
358 // IP-endpoint configs (the common case, and every test fixture)
359 // are unchanged.
360 if let Ok(ip) = host.parse::<IpAddr>() {
361 return Ok(vec![SocketAddr::new(ip, port)]);
362 }
363 // Cache read. An expired entry is treated as a miss and replaced
364 // after the fresh lookup (below); we do not evict here to keep
365 // the critical section to a map read.
366 {
367 let map = self.entries.lock().await;
368 if let Some(entry) = map.get(host) {
369 match entry {
370 CacheEntry::Positive { addrs, expires_at } if *expires_at > Instant::now() => {
371 return Ok(addrs.iter().map(|ip| SocketAddr::new(*ip, port)).collect());
372 }
373 CacheEntry::Negative { expires_at } if *expires_at > Instant::now() => {
374 return Err(std::io::Error::new(
375 std::io::ErrorKind::AddrNotAvailable,
376 format!("cached DNS failure for '{host}'"),
377 ));
378 }
379 _ => {} // expired: fall through to a fresh lookup
380 }
381 }
382 }
383 // Resolve via hickory (async, no blocking-pool getaddrinfo).
384 match self.resolver.resolve_ip(host).await {
385 Ok(addrs) if !addrs.is_empty() => {
386 let ttl = addrs
387 .iter()
388 .map(|(_, t)| {
389 Duration::from_secs(u64::from(*t))
390 .min(self.positive_ttl_cap)
391 .max(POSITIVE_TTL_FLOOR)
392 })
393 .min()
394 .unwrap_or(self.positive_ttl_cap);
395 let ips: Vec<IpAddr> = addrs.iter().map(|(ip, _)| *ip).collect();
396 let expires_at = Instant::now() + ttl;
397 self.insert(
398 host.to_string(),
399 CacheEntry::Positive {
400 addrs: ips.clone(),
401 expires_at,
402 },
403 )
404 .await;
405 Ok(ips.iter().map(|ip| SocketAddr::new(*ip, port)).collect())
406 }
407 Ok(_) => {
408 // No addresses (NXDOMAIN-ish): cache the miss briefly so
409 // a misconfigured hostname does not hammer the resolver.
410 let expires_at = Instant::now() + self.negative_ttl;
411 self.insert(host.to_string(), CacheEntry::Negative { expires_at })
412 .await;
413 Err(std::io::Error::new(
414 std::io::ErrorKind::AddrNotAvailable,
415 format!("'{host}' resolved to no addresses"),
416 ))
417 }
418 Err(e) => {
419 let expires_at = Instant::now() + self.negative_ttl;
420 self.insert(host.to_string(), CacheEntry::Negative { expires_at })
421 .await;
422 Err(std::io::Error::other(format!(
423 "DNS lookup for '{host}' failed: {e}"
424 )))
425 }
426 }
427 }
428
429 /// Insert an entry, evicting expired entries first when the cache is
430 /// at capacity. The eviction pass is O(n) but only runs at the cap;
431 /// the working set is small so this is bounded. If expired eviction
432 /// does not free a slot (every entry still live), the new entry
433 /// replaces the one with the nearest expiry (the next to expire
434 /// anyway), keeping the cache at `max_entries` without dropping a
435 /// long-lived entry.
436 async fn insert(&self, host: String, entry: CacheEntry) {
437 let mut map = self.entries.lock().await;
438 let now = Instant::now();
439 if map.len() >= self.max_entries {
440 // Evict every expired entry first.
441 map.retain(|_, e| match e {
442 CacheEntry::Positive { expires_at, .. } => *expires_at > now,
443 CacheEntry::Negative { expires_at } => *expires_at > now,
444 });
445 }
446 if map.len() >= self.max_entries {
447 // Still full: drop the entry nearest to expiry (the next
448 // natural eviction). A fresh insert always wins over the
449 // soonest-expiring resident.
450 if let Some(victim) = map
451 .iter()
452 .min_by_key(|(_, e)| match e {
453 CacheEntry::Positive { expires_at, .. } => *expires_at,
454 CacheEntry::Negative { expires_at } => *expires_at,
455 })
456 .map(|(k, _)| k.clone())
457 {
458 map.remove(&victim);
459 }
460 }
461 map.insert(host, entry);
462 }
463}
464
465/// Build the endpoint list from a resolved A-record set, pairing each
466/// address with the configured port and a default weight of 1.
467fn endpoints_from_a(addrs: &[(IpAddr, u32)], port: u16) -> Vec<Endpoint> {
468 addrs
469 .iter()
470 .map(|(ip, _)| Endpoint {
471 address: ip.to_string(),
472 port,
473 weight: 1,
474 region: None,
475 zone: None,
476 })
477 .collect()
478}
479
480/// Build the endpoint list from a resolved SRV-record set. Each
481/// `(IpAddr, port)` pair becomes an endpoint with weight 1.
482fn endpoints_from_srv(addrs: &[(IpAddr, u16, u32)]) -> Vec<Endpoint> {
483 addrs
484 .iter()
485 .map(|(ip, port, _)| Endpoint {
486 address: ip.to_string(),
487 port: *port,
488 weight: 1,
489 region: None,
490 zone: None,
491 })
492 .collect()
493}
494
495/// One discovery refresh cycle: resolve, update the endpoint set.
496/// Returns `true` if the resolution succeeded, `false` on failure
497/// (used by the caller to apply backoff).
498async fn refresh_cycle(
499 resolver: &DnsResolver,
500 dns: &DnsDiscovery,
501 lb: &Arc<UpstreamLb>,
502 upstream_name: &str,
503 obs: &Observability,
504) -> bool {
505 obs.record_dns_discovery_refresh(upstream_name);
506 let result = if dns.record_type == "SRV" {
507 resolver.resolve_srv(&dns.hostname).await.map(|resolved| {
508 let ttl = resolved
509 .first()
510 .map(|(_, _, t)| *t)
511 .unwrap_or(dns.refresh_interval_s as u32);
512 let endpoints = endpoints_from_srv(&resolved);
513 (endpoints, ttl)
514 })
515 } else {
516 resolver.resolve_a(&dns.hostname).await.map(|resolved| {
517 let ttl = resolved
518 .first()
519 .map(|(_, t)| *t)
520 .unwrap_or(dns.refresh_interval_s as u32);
521 let endpoints = endpoints_from_a(&resolved, dns.port);
522 (endpoints, ttl)
523 })
524 };
525 // Read the current balancer parameters once (algorithm, slow-start,
526 // health, events) so the live swap uses the same config as the
527 // initial build. A reload that changes these respawns the task.
528 let algorithm = lb.algorithm();
529 let slow_start = lb.slow_start();
530 let health = lb.health_config();
531 let events = lb.events();
532 let peak_ewma = lb.peak_ewma_config();
533 let locality = lb.locality_config();
534 match result {
535 Ok((endpoints, ttl)) => {
536 // min_endpoints floor: if the resolution yielded fewer than
537 // the floor, keep the previous set (do not shrink below the
538 // floor). An empty resolution also falls through here.
539 if (endpoints.len() as u32) < dns.min_endpoints {
540 tracing::warn!(
541 code = "dns_discovery_below_floor",
542 upstream = %upstream_name,
543 resolved = endpoints.len(),
544 min = dns.min_endpoints,
545 "DNS resolution yielded fewer than min_endpoints; keeping previous set"
546 );
547 // Keep the current set; do not update the balancer.
548 return true;
549 }
550 // Update the balancer's endpoint set atomically. Unchanged
551 // addresses keep their in-flight counters and health
552 // trackers; new addresses start fresh. This is the same
553 // hot-swap path reloads use.
554 lb.rebuild_with_resolved_health_and_events(
555 &endpoints,
556 algorithm,
557 slow_start,
558 health,
559 events.as_ref(),
560 peak_ewma.as_deref(),
561 locality.as_deref(),
562 );
563 obs.set_dns_discovery_endpoints(upstream_name, endpoints.len() as i64);
564 tracing::info!(
565 code = "dns_discovery_refreshed",
566 upstream = %upstream_name,
567 endpoints = endpoints.len(),
568 ttl_s = ttl,
569 "DNS discovery refreshed: {} endpoints, TTL {}s",
570 endpoints.len(),
571 ttl
572 );
573 true
574 }
575 Err(err) => {
576 obs.record_dns_discovery_refresh_failure(upstream_name);
577 if dns.fail_open {
578 tracing::warn!(
579 code = "dns_discovery_failed_fail_open",
580 upstream = %upstream_name,
581 "DNS discovery failed; keeping last endpoint set (fail_open): {err}"
582 );
583 // Keep the current set; do not clear.
584 } else {
585 tracing::warn!(
586 code = "dns_discovery_failed_fail_closed",
587 upstream = %upstream_name,
588 "DNS discovery failed; clearing endpoints (fail_open=false): {err}"
589 );
590 // Clear the endpoint set: the upstream answers 503
591 // until DNS recovers.
592 lb.rebuild_with_resolved_health_and_events(
593 &[],
594 algorithm,
595 slow_start,
596 health,
597 events.as_ref(),
598 peak_ewma.as_deref(),
599 locality.as_deref(),
600 );
601 obs.set_dns_discovery_endpoints(upstream_name, 0);
602 }
603 false
604 }
605 }
606}
607
608/// REL-12 (#223): maximum backoff cap (5 minutes). After this cap is
609/// reached, the loop continues at the cap until a successful resolution
610/// resets it.
611const DNS_BACKOFF_MAX_S: u64 = 300;
612
613/// The per-upstream discovery loop. Runs until the task is aborted by a
614/// respawn or shutdown. Each cycle resolves, updates the endpoint set,
615/// and sleeps `refresh_interval_s` seconds. REL-12 (#223): on failure,
616/// applies exponential backoff (doubled each failure, capped at
617/// `DNS_BACKOFF_MAX_S`) to avoid hammering the DNS server during an
618/// outage. A successful resolution resets the backoff to
619/// `refresh_interval_s`.
620async fn discovery_loop(
621 resolver: Arc<DnsResolver>,
622 dns: DnsDiscovery,
623 handle: Arc<UpstreamHandle>,
624 obs: Arc<Observability>,
625) {
626 let upstream_name = handle.name().to_string();
627 let lb = Arc::clone(handle.lb());
628 // REL-12 (#223): current backoff interval. Starts at
629 // refresh_interval_s; doubled on each failure, capped at
630 // DNS_BACKOFF_MAX_S. Reset to refresh_interval_s on success.
631 let mut backoff_s = dns.refresh_interval_s;
632 loop {
633 let ok = refresh_cycle(&resolver, &dns, &lb, &upstream_name, &obs).await;
634 let sleep_s = if ok {
635 // Success: reset backoff to the configured refresh interval.
636 backoff_s = dns.refresh_interval_s;
637 dns.refresh_interval_s
638 } else {
639 // Failure: sleep the current backoff, then double it for
640 // the next failure (capped at DNS_BACKOFF_MAX_S).
641 let current = backoff_s;
642 backoff_s = (backoff_s * 2).min(DNS_BACKOFF_MAX_S);
643 current
644 };
645 tokio::time::sleep(Duration::from_secs(sleep_s)).await;
646 }
647}
648
649/// Owns every DNS discovery task for the running generation. Call
650/// [`DiscoveryTasks::respawn`] on startup and after every snapshot swap;
651/// dropping it (or shutdown) aborts all tasks. Mirrors [`crate::dataplane::active::ActiveProbes`]
652/// (DW-013), the closest precedent for a long-running dataplane task.
653#[derive(Default)]
654pub struct DiscoveryTasks {
655 tasks: JoinSet<()>,
656}
657
658impl DiscoveryTasks {
659 pub fn new() -> Self {
660 Self::default()
661 }
662
663 /// Abort all current discovery tasks and spawn fresh ones for every
664 /// upstream in `snapshot` that configures `dns_discovery`. The
665 /// resolver is shared across all tasks (one resolver, many upstreams)
666 /// and the handle is taken from the CURRENT registry generation
667 /// (call after `DataPlane::refresh`). Upstreams without
668 /// `dns_discovery` spawn nothing.
669 pub fn respawn(
670 &mut self,
671 registry: &crate::dataplane::upstream::UpstreamRegistry,
672 snapshot: &crate::snapshot::Snapshot,
673 resolver: Arc<DnsResolver>,
674 obs: Arc<Observability>,
675 ) {
676 self.abort_all();
677 for u in &snapshot.gateway().upstreams {
678 let Some(dns) = &u.dns_discovery else {
679 continue;
680 };
681 let Some(handle) = registry.get(&u.name) else {
682 continue;
683 };
684 self.tasks.spawn(discovery_loop(
685 Arc::clone(&resolver),
686 dns.clone(),
687 handle,
688 Arc::clone(&obs),
689 ));
690 }
691 }
692
693 /// Abort every discovery task (they stop at the next await point).
694 pub fn abort_all(&mut self) {
695 self.tasks.abort_all();
696 }
697
698 /// Number of live discovery tasks (spawned minus finished/aborted
699 /// that have been reaped). Observability/tests.
700 pub fn task_count(&mut self) -> usize {
701 while self.tasks.try_join_next().is_some() {}
702 self.tasks.len()
703 }
704}