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}