Skip to main content

dynamo_runtime/component/
client.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5    collections::{HashMap, HashSet},
6    sync::{Arc, LazyLock, Mutex as StdMutex},
7    time::Duration,
8};
9
10use anyhow::Result;
11use arc_swap::ArcSwap;
12use futures::StreamExt;
13use tokio::time::Instant;
14
15use crate::component::{Endpoint, Instance};
16use crate::config::environment_names::runtime as env_runtime;
17use crate::discovery::{DiscoveryEvent, DiscoveryInstance, DiscoveryInstanceId};
18use crate::routing_policy::{RoutingOccupancyState, get_or_create_routing_occupancy_state};
19use crate::traits::DistributedRuntimeProvider;
20
21/// Default interval for periodic reconciliation of instance_avail with instance_source
22const DEFAULT_INHIBITED_DURATION_SECS: u64 = 5;
23
24/// Request-path overload is a short-lived routing hint. A worker is probed
25/// again after this cooldown if no authoritative load update arrives first.
26const REQUEST_PATH_OVERLOAD_COOLDOWN: Duration = Duration::from_secs(1);
27
28/// Process-wide inhibited duration, resolved from the environment on first client construction.
29static INHIBITED_DURATION: LazyLock<Duration> =
30    LazyLock::new(|| inhibited_duration_from_env(|name| std::env::var(name).ok()));
31
32fn inhibited_duration_from_env(mut lookup: impl FnMut(&str) -> Option<String>) -> Duration {
33    let seconds = match lookup(env_runtime::DYN_RUNTIME_INHIBITED_DURATION_SECS) {
34        None => DEFAULT_INHIBITED_DURATION_SECS,
35        Some(raw) => match raw.parse::<u64>() {
36            Ok(seconds) => seconds,
37            Err(err) => {
38                tracing::warn!(
39                    value = raw,
40                    %err,
41                    "invalid {}; using the default of {} seconds",
42                    env_runtime::DYN_RUNTIME_INHIBITED_DURATION_SECS,
43                    DEFAULT_INHIBITED_DURATION_SECS,
44                );
45                DEFAULT_INHIBITED_DURATION_SECS
46            }
47        },
48    };
49    Duration::from_secs(seconds)
50}
51
52/// Shared endpoint discovery state for a single endpoint query.
53///
54/// This wraps both the coalesced instance snapshot used for routing decisions
55/// and a raw, lossless per-subscriber event feed used by the response-stream
56/// cancellation watcher. Both outputs are driven by a single underlying
57/// discovery `list_and_watch` task so clients do not multiply control-plane
58/// watches.
59#[derive(Debug)]
60pub(crate) struct EndpointDiscoverySource {
61    instance_source: tokio::sync::watch::Receiver<Vec<Instance>>,
62    event_subscribers: StdMutex<Vec<tokio::sync::mpsc::UnboundedSender<DiscoveryEvent>>>,
63}
64
65pub(crate) struct DiscoveryEventReceiver {
66    receiver: tokio::sync::mpsc::UnboundedReceiver<DiscoveryEvent>,
67    _source: Arc<EndpointDiscoverySource>,
68}
69
70impl std::ops::Deref for DiscoveryEventReceiver {
71    type Target = tokio::sync::mpsc::UnboundedReceiver<DiscoveryEvent>;
72
73    fn deref(&self) -> &Self::Target {
74        &self.receiver
75    }
76}
77
78impl std::ops::DerefMut for DiscoveryEventReceiver {
79    fn deref_mut(&mut self) -> &mut Self::Target {
80        &mut self.receiver
81    }
82}
83
84impl EndpointDiscoverySource {
85    fn new(instance_source: tokio::sync::watch::Receiver<Vec<Instance>>) -> Self {
86        Self {
87            instance_source,
88            event_subscribers: StdMutex::new(Vec::new()),
89        }
90    }
91
92    fn instance_receiver(&self) -> tokio::sync::watch::Receiver<Vec<Instance>> {
93        self.instance_source.clone()
94    }
95
96    fn subscribe_events(&self) -> tokio::sync::mpsc::UnboundedReceiver<DiscoveryEvent> {
97        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
98        self.event_subscribers.lock().unwrap().push(tx);
99        rx
100    }
101
102    fn broadcast_event(&self, event: &DiscoveryEvent) {
103        let subscribers = &mut *self.event_subscribers.lock().unwrap();
104        subscribers.retain(|tx| tx.send(event.clone()).is_ok());
105    }
106}
107
108#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
109pub struct RoutingInstanceCounts {
110    pub discovered: usize,
111    pub routable: usize,
112    pub overloaded: usize,
113    /// IDs not currently reported overloaded, derived from `discovered - overloaded`.
114    pub free: usize,
115}
116
117#[derive(Clone, Debug)]
118pub(crate) struct RoutingInstances {
119    discovered_ids: Vec<u64>,
120    routable_ids: Vec<u64>,
121    monitor_overloaded_ids: HashSet<u64>,
122    request_overload_deadlines: HashMap<u64, Instant>,
123    next_request_overload_expiry: Option<Instant>,
124    overloaded_ids: HashSet<u64>,
125    free_ids: Vec<u64>,
126    routable_id_set: Arc<HashSet<u64>>,
127    /// True after this client has observed at least one discovered instance.
128    /// Once set, a later empty snapshot is authoritative rather than startup
129    /// absence of information.
130    availability_initialized: bool,
131}
132
133impl RoutingInstances {
134    fn new(discovered_ids: Vec<u64>) -> Self {
135        let availability_initialized = !discovered_ids.is_empty();
136        Self::from_parts(
137            discovered_ids.clone(),
138            discovered_ids,
139            HashSet::new(),
140            HashMap::new(),
141            availability_initialized,
142        )
143    }
144
145    fn from_parts(
146        mut discovered_ids: Vec<u64>,
147        mut routable_ids: Vec<u64>,
148        monitor_overloaded_ids: HashSet<u64>,
149        request_overload_deadlines: HashMap<u64, Instant>,
150        availability_initialized: bool,
151    ) -> Self {
152        discovered_ids.sort_unstable();
153        discovered_ids.dedup();
154        routable_ids.sort_unstable();
155        routable_ids.dedup();
156        let mut overloaded_ids = monitor_overloaded_ids.clone();
157        let next_request_overload_expiry = request_overload_deadlines.values().min().copied();
158        overloaded_ids.extend(request_overload_deadlines.keys().copied());
159        let free_ids = Self::derive_free_ids(&routable_ids, &overloaded_ids);
160        let routable_id_set = Arc::new(routable_ids.iter().copied().collect());
161        Self {
162            discovered_ids,
163            routable_ids,
164            monitor_overloaded_ids,
165            request_overload_deadlines,
166            next_request_overload_expiry,
167            overloaded_ids,
168            free_ids,
169            routable_id_set,
170            availability_initialized,
171        }
172    }
173
174    pub(crate) fn discovered_ids(&self) -> &[u64] {
175        &self.discovered_ids
176    }
177
178    pub(crate) fn routable_ids(&self) -> &[u64] {
179        &self.routable_ids
180    }
181
182    fn available_ids(&self) -> Option<Arc<HashSet<u64>>> {
183        self.availability_initialized
184            .then(|| Arc::clone(&self.routable_id_set))
185    }
186
187    pub(crate) fn free_ids(&self) -> &[u64] {
188        &self.free_ids
189    }
190
191    pub(crate) fn counts(&self) -> RoutingInstanceCounts {
192        RoutingInstanceCounts {
193            discovered: self.discovered_ids.len(),
194            routable: self.routable_ids.len(),
195            overloaded: self.overloaded_ids.len(),
196            free: self.free_ids.len(),
197        }
198    }
199
200    pub(crate) fn is_overloaded(&self, instance_id: u64) -> bool {
201        self.overloaded_ids.contains(&instance_id)
202    }
203
204    fn overloaded_ids(&self) -> Option<HashSet<u64>> {
205        if self.overloaded_ids.is_empty() {
206            return None;
207        }
208
209        Some(self.overloaded_ids.clone())
210    }
211
212    fn reconcile_discovered(&self, discovered_ids: Vec<u64>) -> Self {
213        let old_discovered_ids = self.discovered_ids.iter().copied().collect::<HashSet<_>>();
214        let new_discovered_ids = discovered_ids.iter().copied().collect::<HashSet<_>>();
215        let mut monitor_overloaded_ids = self.monitor_overloaded_ids.clone();
216        monitor_overloaded_ids
217            .retain(|id| !old_discovered_ids.contains(id) || new_discovered_ids.contains(id));
218        let mut request_overload_deadlines = self.request_overload_deadlines.clone();
219        request_overload_deadlines
220            .retain(|id, _| !old_discovered_ids.contains(id) || new_discovered_ids.contains(id));
221
222        let availability_initialized = self.availability_initialized || !discovered_ids.is_empty();
223        Self::from_parts(
224            discovered_ids.clone(),
225            discovered_ids,
226            monitor_overloaded_ids,
227            request_overload_deadlines,
228            availability_initialized,
229        )
230    }
231
232    fn report_instance_down(&self, instance_id: u64) -> Self {
233        let routable_ids: Vec<u64> = self
234            .routable_ids
235            .iter()
236            .copied()
237            .filter(|id| *id != instance_id)
238            .collect();
239
240        Self::from_parts(
241            self.discovered_ids.clone(),
242            routable_ids,
243            self.monitor_overloaded_ids.clone(),
244            self.request_overload_deadlines.clone(),
245            self.availability_initialized,
246        )
247    }
248
249    #[cfg(any(test, feature = "testing"))]
250    fn override_routable_ids(&self, routable_ids: Vec<u64>) -> Self {
251        // Route through from_parts so `free_ids` is recomputed from the new
252        // routable set instead of carrying the stale value forward.
253        Self::from_parts(
254            self.discovered_ids.clone(),
255            routable_ids,
256            self.monitor_overloaded_ids.clone(),
257            self.request_overload_deadlines.clone(),
258            self.availability_initialized,
259        )
260    }
261
262    fn set_overloaded(&self, overloaded_ids: HashSet<u64>) -> Self {
263        Self::from_parts(
264            self.discovered_ids.clone(),
265            self.routable_ids.clone(),
266            overloaded_ids,
267            HashMap::new(),
268            self.availability_initialized,
269        )
270    }
271
272    /// Add a single instance to the overloaded set (immediate
273    /// backpressure mark). A monitor update clears all request-path
274    /// marks. Otherwise, each mark expires at its own deadline.
275    fn mark_overloaded_until(&self, instance_id: u64, deadline: Instant) -> Self {
276        let mut request_overload_deadlines = self.request_overload_deadlines.clone();
277        request_overload_deadlines.insert(instance_id, deadline);
278        Self::from_parts(
279            self.discovered_ids.clone(),
280            self.routable_ids.clone(),
281            self.monitor_overloaded_ids.clone(),
282            request_overload_deadlines,
283            self.availability_initialized,
284        )
285    }
286
287    fn has_expired_request_overload(&self, now: Instant) -> bool {
288        self.next_request_overload_expiry
289            .is_some_and(|deadline| deadline <= now)
290    }
291
292    fn clear_expired_request_overloads(&self, now: Instant) -> Self {
293        let mut request_overload_deadlines = self.request_overload_deadlines.clone();
294        request_overload_deadlines.retain(|_, deadline| *deadline > now);
295        Self::from_parts(
296            self.discovered_ids.clone(),
297            self.routable_ids.clone(),
298            self.monitor_overloaded_ids.clone(),
299            request_overload_deadlines,
300            self.availability_initialized,
301        )
302    }
303
304    fn clear_overloaded_for_removed(&self, removed_ids: &HashSet<u64>) -> Self {
305        let mut monitor_overloaded_ids = self.monitor_overloaded_ids.clone();
306        monitor_overloaded_ids.retain(|id| !removed_ids.contains(id));
307        let mut request_overload_deadlines = self.request_overload_deadlines.clone();
308        request_overload_deadlines.retain(|id, _| !removed_ids.contains(id));
309        Self::from_parts(
310            self.discovered_ids.clone(),
311            self.routable_ids.clone(),
312            monitor_overloaded_ids,
313            request_overload_deadlines,
314            self.availability_initialized,
315        )
316    }
317
318    fn derive_free_ids(routable_ids: &[u64], overloaded_ids: &HashSet<u64>) -> Vec<u64> {
319        if overloaded_ids.is_empty() {
320            return routable_ids.to_vec();
321        }
322
323        routable_ids
324            .iter()
325            .copied()
326            .filter(|id| !overloaded_ids.contains(id))
327            .collect()
328    }
329}
330
331#[derive(Debug)]
332struct RoutingInstancesState {
333    snapshot: ArcSwap<RoutingInstances>,
334    update_lock: StdMutex<()>,
335    instance_avail_tx: tokio::sync::watch::Sender<Vec<u64>>,
336}
337
338impl RoutingInstancesState {
339    fn new(discovered_ids: Vec<u64>) -> (Self, tokio::sync::watch::Receiver<Vec<u64>>) {
340        let snapshot = RoutingInstances::new(discovered_ids);
341        let (instance_avail_tx, instance_avail_rx) =
342            tokio::sync::watch::channel(snapshot.routable_ids().to_vec());
343        (
344            Self {
345                snapshot: ArcSwap::from_pointee(snapshot),
346                update_lock: StdMutex::new(()),
347                instance_avail_tx,
348            },
349            instance_avail_rx,
350        )
351    }
352
353    fn snapshot(&self) -> arc_swap::Guard<Arc<RoutingInstances>> {
354        let now = Instant::now();
355        let current = self.snapshot.load();
356        if !current.has_expired_request_overload(now) {
357            return current;
358        }
359        drop(current);
360
361        self.reconcile_expired_request_overloads(now);
362        self.snapshot.load()
363    }
364
365    fn reconcile_expired_request_overloads(&self, now: Instant) {
366        let _guard = self.update_lock.lock().unwrap();
367        let current = self.snapshot.load();
368        if !current.has_expired_request_overload(now) {
369            return;
370        }
371
372        let next = Arc::new(current.clear_expired_request_overloads(now));
373        self.snapshot.store(next);
374    }
375
376    fn update(
377        &self,
378        update: impl FnOnce(&RoutingInstances) -> RoutingInstances,
379        publish_routable_ids: bool,
380    ) -> Arc<RoutingInstances> {
381        let _guard = self.update_lock.lock().unwrap();
382        let current = self.snapshot.load();
383        let next = Arc::new(update(&current));
384        self.snapshot.store(next.clone());
385        if publish_routable_ids {
386            self.publish_routable_ids(&next);
387        }
388        next
389    }
390
391    fn publish_routable_ids(&self, routing_instances: &RoutingInstances) {
392        let _ = self
393            .instance_avail_tx
394            .send(routing_instances.routable_ids().to_vec());
395    }
396
397    fn routable_ids(&self) -> Vec<u64> {
398        self.snapshot().routable_ids().to_vec()
399    }
400
401    fn available_ids(&self) -> Option<Arc<HashSet<u64>>> {
402        self.snapshot().available_ids()
403    }
404
405    fn free_ids(&self) -> Vec<u64> {
406        self.snapshot().free_ids.clone()
407    }
408
409    fn counts(&self) -> RoutingInstanceCounts {
410        self.snapshot().counts()
411    }
412
413    fn overloaded_ids(&self) -> Option<HashSet<u64>> {
414        self.snapshot().overloaded_ids()
415    }
416
417    fn report_instance_down(&self, instance_id: u64) {
418        self.update(|current| current.report_instance_down(instance_id), true);
419    }
420
421    fn set_overloaded_instances(&self, overloaded_instance_ids: &[u64]) -> bool {
422        let overloaded_ids = overloaded_instance_ids
423            .iter()
424            .copied()
425            .collect::<HashSet<_>>();
426        let _guard = self.update_lock.lock().unwrap();
427        let current = self.snapshot.load();
428        let next = Arc::new(current.set_overloaded(overloaded_ids));
429        let effective_changed = current.overloaded_ids != next.overloaded_ids;
430        let source_changed = current.monitor_overloaded_ids != next.monitor_overloaded_ids
431            || !current.request_overload_deadlines.is_empty();
432        if !source_changed {
433            return false;
434        }
435
436        self.snapshot.store(next);
437        effective_changed
438    }
439
440    fn mark_overloaded_immediate(&self, instance_id: u64) {
441        self.mark_overloaded_until(instance_id, Instant::now() + REQUEST_PATH_OVERLOAD_COOLDOWN);
442    }
443
444    fn mark_overloaded_until(&self, instance_id: u64, deadline: Instant) {
445        let _guard = self.update_lock.lock().unwrap();
446        let current = self.snapshot.load();
447        let next = Arc::new(current.mark_overloaded_until(instance_id, deadline));
448        self.snapshot.store(next);
449    }
450
451    fn overload_reconciliation_needed(&self) -> bool {
452        !self.snapshot().request_overload_deadlines.is_empty()
453    }
454
455    fn clear_overloaded_for_removed(&self, removed_instance_ids: &[u64]) {
456        if removed_instance_ids.is_empty() {
457            return;
458        }
459
460        let removed_ids = removed_instance_ids.iter().copied().collect::<HashSet<_>>();
461        self.update(
462            move |current| current.clear_overloaded_for_removed(&removed_ids),
463            false,
464        );
465    }
466
467    fn reconcile_discovered(&self, discovered_ids: Vec<u64>) -> Arc<RoutingInstances> {
468        self.update(
469            move |current| current.reconcile_discovered(discovered_ids),
470            true,
471        )
472    }
473
474    #[cfg(any(test, feature = "testing"))]
475    fn override_routable_ids(&self, ids: Vec<u64>) {
476        self.update(move |current| current.override_routable_ids(ids), true);
477    }
478}
479
480#[derive(Clone, Debug)]
481pub struct Client {
482    // This is me
483    pub endpoint: Endpoint,
484    // Shared endpoint discovery source backing both snapshots and raw events.
485    endpoint_discovery_source: Arc<EndpointDiscoverySource>,
486    // These are the remotes I know about from watching key-value store
487    pub instance_source: Arc<tokio::sync::watch::Receiver<Vec<Instance>>>,
488    // Immutable routing snapshot. Free IDs are derived from discovered IDs and overloaded IDs.
489    routing_instances: Arc<RoutingInstancesState>,
490    // Client clones and standalone watchers jointly own the reconciliation task.
491    instance_avail_owner: Arc<tokio::sync::watch::Receiver<Vec<u64>>>,
492    /// Interval for periodic reconciliation of instance_avail with instance_source.
493    /// This ensures instances removed via `report_instance_down` are eventually restored.
494    /// A zero value disables local worker inhibition.
495    reconcile_interval: Duration,
496}
497
498impl Client {
499    // Client with auto-discover instances using key-value store
500    pub(crate) async fn new(endpoint: Endpoint) -> Result<Self> {
501        Self::with_reconcile_interval(endpoint, *INHIBITED_DURATION).await
502    }
503
504    /// Like [`Self::new`], but the `monitor_instance_source` background task
505    /// is bound to `cancel_token` instead of the process-wide primary token.
506    /// See [`Self::with_reconcile_interval_and_cancellation`] for why a
507    /// caller whose own scope is narrower than the process needs this.
508    pub(crate) async fn with_cancellation(
509        endpoint: Endpoint,
510        cancel_token: tokio_util::sync::CancellationToken,
511    ) -> Result<Self> {
512        Self::with_reconcile_interval_and_cancellation(endpoint, *INHIBITED_DURATION, cancel_token)
513            .await
514    }
515
516    /// Create a client with a custom reconcile interval.
517    /// The reconcile interval controls how often `instance_avail` is reset to match
518    /// `instance_source`, restoring any instances removed via `report_instance_down`.
519    pub(crate) async fn with_reconcile_interval(
520        endpoint: Endpoint,
521        reconcile_interval: Duration,
522    ) -> Result<Self> {
523        let cancel_token = endpoint.drt().primary_token();
524        Self::with_reconcile_interval_and_cancellation(endpoint, reconcile_interval, cancel_token)
525            .await
526    }
527
528    /// Like [`Self::with_reconcile_interval`], but the `monitor_instance_source`
529    /// background task is bound to `cancel_token` rather than the process-wide
530    /// primary token.
531    ///
532    /// A caller that builds a `Client` scoped to something narrower than the
533    /// process — a monitor bound to one `WorkerSet`'s lifecycle, say — must use
534    /// this constructor. `Client` is `Clone`, and `monitor_instance_source`
535    /// captures its own clone before returning, so dropping every `Client`
536    /// handle the caller holds does not stop that task; only cancelling its
537    /// token does. Built through [`Self::new`] or [`Self::with_reconcile_interval`]
538    /// instead, that task runs until process shutdown regardless of how long
539    /// the caller actually keeps the `Client` around.
540    pub(crate) async fn with_reconcile_interval_and_cancellation(
541        endpoint: Endpoint,
542        reconcile_interval: Duration,
543        cancel_token: tokio_util::sync::CancellationToken,
544    ) -> Result<Self> {
545        tracing::trace!(
546            "Client::new_dynamic: Creating dynamic client for endpoint: {}",
547            endpoint.id()
548        );
549        let endpoint_discovery_source =
550            Self::get_or_create_dynamic_discovery_source(&endpoint).await?;
551        let instance_source = Arc::new(endpoint_discovery_source.instance_receiver());
552
553        // Seed instance_avail from the current instance_source snapshot so that
554        // callers who proceed immediately after wait_for_instances (which reads
555        // instance_source directly) will also find instances in instance_avail
556        // (which is read by the routing methods like random/round_robin).
557        let initial_ids: Vec<u64> = instance_source
558            .borrow()
559            .iter()
560            .map(|instance| instance.id())
561            .collect();
562        let (routing_instances, instance_avail_owner) = RoutingInstancesState::new(initial_ids);
563        let client = Client {
564            endpoint: endpoint.clone(),
565            endpoint_discovery_source,
566            instance_source: instance_source.clone(),
567            routing_instances: Arc::new(routing_instances),
568            instance_avail_owner: Arc::new(instance_avail_owner),
569            reconcile_interval,
570        };
571        client.monitor_instance_source_with_cancellation(cancel_token, true);
572        Ok(client)
573    }
574
575    /// Instances available from watching key-value store
576    pub fn instances(&self) -> Vec<Instance> {
577        self.instance_source.borrow().clone()
578    }
579
580    pub fn instance_ids(&self) -> Vec<u64> {
581        self.instances().into_iter().map(|ep| ep.id()).collect()
582    }
583
584    /// Whether the latest discovery snapshot contains this instance, including inhibited workers.
585    pub fn is_instance_discovered(&self, instance_id: u64) -> bool {
586        self.routing_instances
587            .snapshot()
588            .discovered_ids()
589            .binary_search(&instance_id)
590            .is_ok()
591    }
592
593    pub fn instance_ids_avail(&self) -> Vec<u64> {
594        self.routing_instances.routable_ids()
595    }
596
597    /// Routable instance ids excluding those currently flagged overloaded — the set used
598    /// for load-aware (random / round-robin) worker selection.
599    pub fn instance_ids_free(&self) -> Vec<u64> {
600        self.routing_instances.free_ids()
601    }
602
603    pub(crate) fn routing_instances(&self) -> arc_swap::Guard<Arc<RoutingInstances>> {
604        self.routing_instances.snapshot()
605    }
606
607    pub fn routing_instance_counts(&self) -> RoutingInstanceCounts {
608        self.routing_instances.counts()
609    }
610
611    /// Get a watcher for available instance IDs
612    pub fn instance_avail_watcher(&self) -> tokio::sync::watch::Receiver<Vec<u64>> {
613        self.instance_avail_owner.as_ref().clone()
614    }
615
616    /// Create a client view whose routable instances are restricted by a caller-owned
617    /// admission set.
618    ///
619    /// Endpoint discovery remains the source of connection metadata and hard availability. The
620    /// returned client publishes only the intersection of that endpoint membership and
621    /// `admitted_ids`, allowing a higher-level controller to keep discovered-but-unvalidated
622    /// instances out of a routing group. The view has independent overload and fault-inhibition
623    /// state, just like a freshly constructed client.
624    pub fn with_admitted_instances(
625        &self,
626        admitted_ids: tokio::sync::watch::Receiver<Vec<u64>>,
627    ) -> Self {
628        self.with_admitted_instances_and_cancellation(
629            admitted_ids,
630            self.endpoint.drt().primary_token(),
631        )
632    }
633
634    /// Like [`Self::with_admitted_instances`], with a lifecycle token for construction-time
635    /// cancellation by an owning controller.
636    pub fn with_admitted_instances_and_cancellation(
637        &self,
638        mut admitted_ids: tokio::sync::watch::Receiver<Vec<u64>>,
639        cancel_token: tokio_util::sync::CancellationToken,
640    ) -> Self {
641        let mut endpoint_instances = self.instance_source.as_ref().clone();
642        let initial = Self::filter_admitted_instances(
643            endpoint_instances.borrow().as_slice(),
644            admitted_ids.borrow().as_slice(),
645        );
646        let initial_ids = initial.iter().map(Instance::id).collect::<Vec<_>>();
647        let (instance_tx, instance_rx) = tokio::sync::watch::channel(initial);
648        let updater_cancel = cancel_token.clone();
649
650        tokio::spawn(async move {
651            loop {
652                tokio::select! {
653                    _ = updater_cancel.cancelled() => break,
654                    _ = instance_tx.closed() => break,
655                    result = endpoint_instances.changed() => {
656                        if result.is_err() {
657                            break;
658                        }
659                    }
660                    result = admitted_ids.changed() => {
661                        if result.is_err() {
662                            break;
663                        }
664                    }
665                }
666
667                let next = Self::filter_admitted_instances(
668                    endpoint_instances.borrow_and_update().as_slice(),
669                    admitted_ids.borrow_and_update().as_slice(),
670                );
671                let changed = *instance_tx.borrow() != next;
672                if changed && instance_tx.send(next).is_err() {
673                    break;
674                }
675            }
676        });
677
678        let (routing_instances, instance_avail_owner) = RoutingInstancesState::new(initial_ids);
679        let client = Self {
680            endpoint: self.endpoint.clone(),
681            endpoint_discovery_source: self.endpoint_discovery_source.clone(),
682            instance_source: Arc::new(instance_rx),
683            routing_instances: Arc::new(routing_instances),
684            instance_avail_owner: Arc::new(instance_avail_owner),
685            reconcile_interval: self.reconcile_interval,
686        };
687        client.monitor_instance_source_with_cancellation(cancel_token, false);
688        client
689    }
690
691    fn filter_admitted_instances(instances: &[Instance], admitted_ids: &[u64]) -> Vec<Instance> {
692        if admitted_ids.is_empty() {
693            return Vec::new();
694        }
695
696        let admitted = admitted_ids.iter().copied().collect::<HashSet<_>>();
697        instances
698            .iter()
699            .filter(|instance| admitted.contains(&instance.id()))
700            .cloned()
701            .collect()
702    }
703
704    /// Subscribe to raw discovery events for this endpoint.
705    ///
706    /// Unlike `instance_source`, this feed does not coalesce remove→add pairs,
707    /// so consumers can react to every removal event exactly once.
708    pub(crate) fn subscribe_discovery_events(&self) -> DiscoveryEventReceiver {
709        DiscoveryEventReceiver {
710            receiver: self.endpoint_discovery_source.subscribe_events(),
711            _source: self.endpoint_discovery_source.clone(),
712        }
713    }
714
715    /// Wait for at least one Instance to be available for this Endpoint
716    pub async fn wait_for_instances(&self) -> Result<Vec<Instance>> {
717        tracing::trace!(
718            "wait_for_instances: Starting wait for endpoint: {}",
719            self.endpoint.id()
720        );
721        let mut rx = self.instance_source.as_ref().clone();
722        // wait for there to be 1 or more endpoints
723        let mut instances: Vec<Instance>;
724        loop {
725            instances = rx.borrow_and_update().to_vec();
726            if instances.is_empty() {
727                rx.changed().await?;
728            } else {
729                tracing::info!(
730                    "wait_for_instances: Found {} instance(s) for endpoint: {}",
731                    instances.len(),
732                    self.endpoint.id()
733                );
734                break;
735            }
736        }
737        Ok(instances)
738    }
739
740    /// Mark an instance as down/unavailable
741    pub fn report_instance_down(&self, instance_id: u64) {
742        if self.reconcile_interval.is_zero() {
743            tracing::debug!(
744                instance_id,
745                "local worker inhibition is disabled; leaving instance routable"
746            );
747            return;
748        }
749
750        self.routing_instances.report_instance_down(instance_id);
751        tracing::debug!("inhibiting instance {instance_id}");
752    }
753
754    /// Replace the set of overloaded instances reported by the worker monitor.
755    /// Returns true when this changes the effective overloaded set.
756    pub fn set_overloaded_instances(&self, overloaded_instance_ids: &[u64]) -> bool {
757        self.routing_instances
758            .set_overloaded_instances(overloaded_instance_ids)
759    }
760
761    /// Whether an unexpired request-path overload lease remains after the
762    /// monitor's most recent metric publication.
763    pub fn overload_reconciliation_needed(&self) -> bool {
764        self.routing_instances.overload_reconciliation_needed()
765    }
766
767    /// Mark an instance overloaded immediately after a worker-scoped
768    /// `WorkerOverloaded` response. This is backpressure, not a fault, so it
769    /// does not call `report_instance_down`. A fresh worker-monitor publication
770    /// clears this hint early. Its bounded lease otherwise permits a recovery probe.
771    pub fn mark_overloaded_immediate(&self, instance_id: u64) {
772        self.routing_instances
773            .mark_overloaded_immediate(instance_id);
774        tracing::debug!(
775            instance_id,
776            "marking instance overloaded with a bounded backpressure lease"
777        );
778    }
779
780    pub fn clear_overloaded_instances_for_removed(&self, removed_instance_ids: &[u64]) {
781        self.routing_instances
782            .clear_overloaded_for_removed(removed_instance_ids);
783    }
784
785    pub fn overloaded_instance_ids(&self) -> Option<HashSet<u64>> {
786        self.routing_instances.overloaded_ids()
787    }
788
789    /// Workers currently eligible for selection: discovered and not locally
790    /// inhibited by [`Self::report_instance_down`].
791    ///
792    /// This hard-availability snapshot is separate from transient overload.
793    /// `None` means this client has not discovered an instance yet. After the
794    /// first discovery, `Some` is authoritative, including `Some(empty)` when
795    /// the last previously discovered worker is removed.
796    pub fn available_instance_ids(&self) -> Option<Arc<HashSet<u64>>> {
797        self.routing_instances.available_ids()
798    }
799
800    /// Monitor the key-value instance source and update instance_avail.
801    ///
802    /// This function also performs periodic reconciliation: if `instance_source` hasn't
803    /// changed for `reconcile_interval`, we reset `instance_avail` to match
804    /// `instance_source`. This ensures instances removed via `report_instance_down`
805    /// are eventually restored even if the discovery source doesn't emit updates.
806    ///
807    /// The spawned task runs until `cancel_token` cancels. A caller that wants
808    /// this task to outlive nothing shorter than the process should pass
809    /// `self.endpoint.drt().primary_token()`, as [`Self::new`] does.
810    fn monitor_instance_source_with_cancellation(
811        &self,
812        cancel_token: tokio_util::sync::CancellationToken,
813        prune_shared_occupancy: bool,
814    ) {
815        let reconcile_interval = self.reconcile_interval;
816        let endpoint = self.endpoint.clone();
817        let endpoint_discovery_source = self.endpoint_discovery_source.clone();
818        let routing_instances = self.routing_instances.clone();
819        let instance_source = self.instance_source.clone();
820        let endpoint_id = self.endpoint.id();
821        tokio::task::spawn(async move {
822            let mut rx = instance_source.as_ref().clone();
823            while !cancel_token.is_cancelled() {
824                let instance_ids: Vec<u64> = rx
825                    .borrow_and_update()
826                    .iter()
827                    .map(|instance| instance.id())
828                    .collect();
829
830                let snapshot = routing_instances.reconcile_discovered(instance_ids);
831
832                // Clean up stale occupancy counters for instances that no longer exist.
833                if prune_shared_occupancy {
834                    let registry = endpoint.drt().routing_occupancy_states();
835                    if let Ok(registry) = registry.try_lock()
836                        && let Some(weak) = registry.get(&endpoint)
837                        && let Some(state) = weak.upgrade()
838                    {
839                        state.retain(snapshot.discovered_ids());
840                    }
841                }
842
843                tokio::select! {
844                    _ = cancel_token.cancelled() => break,
845                    _ = routing_instances.instance_avail_tx.closed() => break,
846                    result = rx.changed() => {
847                        if let Err(err) = result {
848                            tracing::error!(
849                                "monitor_instance_source: The Sender is dropped: {err}, endpoint={endpoint_id}",
850                            );
851                            cancel_token.cancel();
852                        }
853                    }
854                    _ = tokio::time::sleep(reconcile_interval), if !reconcile_interval.is_zero() => {
855                        tracing::trace!(
856                            "monitor_instance_source: periodic reconciliation for endpoint={endpoint_id}",
857                        );
858                    }
859                }
860            }
861            drop(endpoint_discovery_source);
862        });
863    }
864
865    /// Simulate a complete discovery snapshot for testing.
866    #[cfg(any(test, feature = "testing"))]
867    pub fn override_discovered_instances(&self, ids: Vec<u64>) {
868        self.reconcile_discovered_instances(ids);
869    }
870
871    /// Override routable IDs for testing while preserving discovery membership.
872    #[cfg(any(test, feature = "testing"))]
873    pub fn override_instance_avail(&self, ids: Vec<u64>) {
874        self.routing_instances.override_routable_ids(ids);
875    }
876
877    fn reconcile_discovered_instances(&self, discovered_ids: Vec<u64>) -> Arc<RoutingInstances> {
878        self.routing_instances.reconcile_discovered(discovered_ids)
879    }
880
881    async fn get_or_create_dynamic_discovery_source(
882        endpoint: &Endpoint,
883    ) -> Result<Arc<EndpointDiscoverySource>> {
884        let drt = endpoint.drt();
885        let sources = drt.endpoint_discovery_sources();
886        let mut sources = sources.lock().await;
887
888        if let Some(source) = sources.get(endpoint) {
889            if let Some(source) = source.upgrade() {
890                return Ok(source);
891            } else {
892                sources.remove(endpoint);
893            }
894        }
895
896        let discovery = drt.discovery();
897        let discovery_query = crate::discovery::DiscoveryQuery::Endpoint {
898            namespace: endpoint.component.namespace.name.clone(),
899            component: endpoint.component.name.clone(),
900            endpoint: endpoint.name.clone(),
901        };
902
903        let mut discovery_stream = discovery
904            .list_and_watch(discovery_query.clone(), None)
905            .await?;
906        let (watch_tx, watch_rx) = tokio::sync::watch::channel(vec![]);
907        let discovery_source = Arc::new(EndpointDiscoverySource::new(watch_rx));
908
909        let secondary = endpoint.component.drt.runtime().secondary().clone();
910        let discovery_source_task = Arc::downgrade(&discovery_source);
911
912        secondary.spawn(async move {
913            tracing::trace!("endpoint_watcher: Starting for discovery query: {:?}", discovery_query);
914            let mut map: HashMap<u64, Instance> = HashMap::new();
915
916            loop {
917                let discovery_event = tokio::select! {
918                    _ = watch_tx.closed() => {
919                        break;
920                    }
921                    discovery_event = discovery_stream.next() => {
922                        match discovery_event {
923                            Some(Ok(event)) => {
924                                event
925                            },
926                            Some(Err(e)) => {
927                                tracing::error!("endpoint_watcher: discovery stream error: {}; shutting down for discovery query: {:?}", e, discovery_query);
928                                break;
929                            }
930                            None => {
931                                break;
932                            }
933                        }
934                    }
935                };
936
937                if let Some(discovery_source) = discovery_source_task.upgrade() {
938                    discovery_source.broadcast_event(&discovery_event);
939                }
940
941                match discovery_event {
942                    DiscoveryEvent::Added(DiscoveryInstance::Endpoint(instance)) => {
943                        map.insert(instance.instance_id, instance);
944                    }
945                    DiscoveryEvent::Added(_) => {}
946                    DiscoveryEvent::ModelTaintsUpdated(_) => {}
947                    DiscoveryEvent::Removed(id) => {
948                        if let DiscoveryInstanceId::Endpoint(endpoint_id) = id {
949                            map.remove(&endpoint_id.instance_id);
950                        }
951                    }
952                }
953
954                let instances: Vec<Instance> = map.values().cloned().collect();
955                if watch_tx.send(instances).is_err() {
956                    break;
957                }
958            }
959            let _ = watch_tx.send(vec![]);
960        });
961
962        sources.insert(endpoint.clone(), Arc::downgrade(&discovery_source));
963        Ok(discovery_source)
964    }
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970    use crate::{DistributedRuntime, Runtime, distributed::DistributedConfig};
971
972    async fn wait_for_discovery_event(
973        receiver: &mut DiscoveryEventReceiver,
974        predicate: impl Fn(&DiscoveryEvent) -> bool,
975    ) {
976        tokio::time::timeout(Duration::from_secs(1), async {
977            loop {
978                let event = receiver.recv().await.expect("discovery event feed closed");
979                if predicate(&event) {
980                    return;
981                }
982            }
983        })
984        .await
985        .expect("expected discovery event was not received");
986    }
987
988    async fn wait_for_watch_state<T>(
989        receiver: &mut tokio::sync::watch::Receiver<Vec<T>>,
990        predicate: impl Fn(&[T]) -> bool,
991    ) {
992        tokio::time::timeout(Duration::from_secs(1), async {
993            loop {
994                if predicate(receiver.borrow_and_update().as_slice()) {
995                    return;
996                }
997                receiver
998                    .changed()
999                    .await
1000                    .expect("instance availability feed closed");
1001            }
1002        })
1003        .await
1004        .expect("expected instance availability state was not observed");
1005    }
1006
1007    #[test]
1008    fn test_inhibited_duration_from_env() {
1009        assert_eq!(
1010            inhibited_duration_from_env(|_| None),
1011            Duration::from_secs(DEFAULT_INHIBITED_DURATION_SECS)
1012        );
1013        assert_eq!(
1014            inhibited_duration_from_env(|_| Some("17".to_string())),
1015            Duration::from_secs(17)
1016        );
1017        assert_eq!(
1018            inhibited_duration_from_env(|_| Some("0".to_string())),
1019            Duration::ZERO
1020        );
1021        assert_eq!(
1022            inhibited_duration_from_env(|_| Some("invalid".to_string())),
1023            Duration::from_secs(DEFAULT_INHIBITED_DURATION_SECS)
1024        );
1025    }
1026
1027    #[tokio::test]
1028    async fn dropping_last_client_releases_routing_state() {
1029        let rt = Runtime::from_current().unwrap();
1030        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1031            .await
1032            .unwrap();
1033        let endpoint = drt
1034            .namespace("test_client_lifecycle".to_string())
1035            .unwrap()
1036            .component("test_component".to_string())
1037            .unwrap()
1038            .endpoint("decode".to_string());
1039        let client = endpoint.client().await.unwrap();
1040        let routing_instances = Arc::downgrade(&client.routing_instances);
1041        let discovery_source = Arc::downgrade(&client.endpoint_discovery_source);
1042        let mut raw_watcher = client.instance_source.as_ref().clone();
1043        let mut watcher = client.instance_avail_watcher();
1044        let mut event_watcher = client.subscribe_discovery_events();
1045        raw_watcher.borrow_and_update();
1046        watcher.borrow_and_update();
1047
1048        drop(client);
1049        assert!(routing_instances.upgrade().is_some());
1050        assert!(discovery_source.upgrade().is_some());
1051
1052        endpoint.register_endpoint_instance().await.unwrap();
1053        wait_for_watch_state(&mut watcher, |instances| instances.len() == 1).await;
1054        wait_for_discovery_event(&mut event_watcher, |event| {
1055            matches!(event, DiscoveryEvent::Added(DiscoveryInstance::Endpoint(_)))
1056        })
1057        .await;
1058
1059        endpoint.unregister_endpoint_instance().await.unwrap();
1060        wait_for_watch_state(&mut watcher, |instances| instances.is_empty()).await;
1061        assert!(raw_watcher.borrow_and_update().is_empty());
1062        wait_for_discovery_event(&mut event_watcher, |event| {
1063            matches!(event, DiscoveryEvent::Removed(_))
1064        })
1065        .await;
1066
1067        drop(watcher);
1068
1069        tokio::time::timeout(Duration::from_secs(1), async {
1070            while routing_instances.strong_count() != 0 {
1071                tokio::task::yield_now().await;
1072            }
1073        })
1074        .await
1075        .expect("client monitor retained state after its last observer was dropped");
1076        assert!(discovery_source.upgrade().is_some());
1077
1078        endpoint.register_endpoint_instance().await.unwrap();
1079        wait_for_watch_state(&mut raw_watcher, |instances| instances.len() == 1).await;
1080        wait_for_discovery_event(&mut event_watcher, |event| {
1081            matches!(event, DiscoveryEvent::Added(DiscoveryInstance::Endpoint(_)))
1082        })
1083        .await;
1084        endpoint.unregister_endpoint_instance().await.unwrap();
1085        wait_for_watch_state(&mut raw_watcher, |instances| instances.is_empty()).await;
1086        wait_for_discovery_event(&mut event_watcher, |event| {
1087            matches!(event, DiscoveryEvent::Removed(_))
1088        })
1089        .await;
1090
1091        drop(event_watcher);
1092        tokio::time::timeout(Duration::from_secs(1), async {
1093            while discovery_source.strong_count() != 0 {
1094                tokio::task::yield_now().await;
1095            }
1096        })
1097        .await
1098        .expect("discovery event receiver retained its source after being dropped");
1099
1100        rt.shutdown();
1101    }
1102
1103    /// Test that instances removed via report_instance_down are restored after
1104    /// the reconciliation interval elapses.
1105    #[tokio::test]
1106    async fn test_instance_reconciliation() {
1107        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(100);
1108
1109        let rt = Runtime::from_current().unwrap();
1110        // Use process_local config to avoid needing etcd/nats
1111        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1112            .await
1113            .unwrap();
1114        let ns = drt.namespace("test_reconciliation".to_string()).unwrap();
1115        let component = ns.component("test_component".to_string()).unwrap();
1116        let endpoint = component.endpoint("test_endpoint".to_string());
1117
1118        // Use a short reconcile interval for faster tests
1119        let client = Client::with_reconcile_interval(endpoint, TEST_RECONCILE_INTERVAL)
1120            .await
1121            .unwrap();
1122
1123        // Initially, instance_avail should be empty (no registered instances)
1124        assert!(client.instance_ids_avail().is_empty());
1125
1126        // For this test, we'll directly manipulate instance_avail and verify reconciliation
1127        // Store some test IDs
1128        client.override_instance_avail(vec![1, 2, 3]);
1129
1130        assert_eq!(client.instance_ids_avail(), vec![1u64, 2, 3]);
1131
1132        // Simulate report_instance_down removing instance 2
1133        client.report_instance_down(2);
1134        assert_eq!(client.instance_ids_avail(), vec![1u64, 3]);
1135
1136        // Wait for reconciliation interval + buffer
1137        // The monitor_instance_source will reset instance_avail to match instance_source
1138        // Since instance_source is empty, after reconciliation instance_avail should be empty
1139        tokio::time::sleep(TEST_RECONCILE_INTERVAL + Duration::from_millis(50)).await;
1140
1141        // After reconciliation, instance_avail should match instance_source (which is empty)
1142        assert!(
1143            client.instance_ids_avail().is_empty(),
1144            "After reconciliation, instance_avail should match instance_source"
1145        );
1146
1147        rt.shutdown();
1148    }
1149
1150    /// A zero inhibited duration disables local worker inhibition.
1151    #[tokio::test]
1152    async fn test_zero_inhibited_duration_leaves_instance_routable() {
1153        let rt = Runtime::from_current().unwrap();
1154        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1155            .await
1156            .unwrap();
1157        let ns = drt
1158            .namespace("test_disabled_inhibition".to_string())
1159            .unwrap();
1160        let component = ns.component("test_component".to_string()).unwrap();
1161        let endpoint = component.endpoint("test_endpoint".to_string());
1162
1163        let client = Client::with_reconcile_interval(endpoint, Duration::ZERO)
1164            .await
1165            .unwrap();
1166
1167        client.override_instance_avail(vec![1, 2, 3]);
1168        client.report_instance_down(2);
1169
1170        assert_eq!(
1171            client.instance_ids_avail(),
1172            vec![1, 2, 3],
1173            "a zero inhibited duration should leave the reported instance routable"
1174        );
1175
1176        rt.shutdown();
1177    }
1178
1179    /// Test that report_instance_down correctly removes an instance from instance_avail.
1180    #[tokio::test]
1181    async fn test_report_instance_down() {
1182        let rt = Runtime::from_current().unwrap();
1183        // Use process_local config to avoid needing etcd/nats
1184        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1185            .await
1186            .unwrap();
1187        let ns = drt.namespace("test_report_down".to_string()).unwrap();
1188        let component = ns.component("test_component".to_string()).unwrap();
1189        let endpoint = component.endpoint("test_endpoint".to_string());
1190
1191        let client = endpoint.client().await.unwrap();
1192
1193        // Manually set up instance_avail with test instances
1194        client.override_instance_avail(vec![1, 2, 3]);
1195        assert_eq!(client.instance_ids_avail(), vec![1u64, 2, 3]);
1196
1197        // Report instance 2 as down
1198        client.report_instance_down(2);
1199
1200        // Verify instance 2 is removed
1201        let avail = client.instance_ids_avail();
1202        assert!(avail.contains(&1), "Instance 1 should still be available");
1203        assert!(
1204            !avail.contains(&2),
1205            "Instance 2 should be removed after report_instance_down"
1206        );
1207        assert!(avail.contains(&3), "Instance 3 should still be available");
1208
1209        rt.shutdown();
1210    }
1211
1212    #[tokio::test]
1213    async fn test_overloaded_instance_ids_returns_none_when_empty() {
1214        let rt = Runtime::from_current().unwrap();
1215        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1216            .await
1217            .unwrap();
1218        let ns = drt.namespace("test_overloaded_ids".to_string()).unwrap();
1219        let component = ns.component("test_component".to_string()).unwrap();
1220        let endpoint = component.endpoint("test_endpoint".to_string());
1221        let client = endpoint.client().await.unwrap();
1222
1223        assert_eq!(client.overloaded_instance_ids(), None);
1224
1225        assert!(client.set_overloaded_instances(&[7]));
1226        assert_eq!(client.overloaded_instance_ids(), Some(HashSet::from([7])));
1227        assert!(!client.set_overloaded_instances(&[7]));
1228
1229        assert!(client.set_overloaded_instances(&[]));
1230        assert_eq!(client.overloaded_instance_ids(), None);
1231        assert!(!client.set_overloaded_instances(&[]));
1232
1233        rt.shutdown();
1234    }
1235
1236    #[tokio::test(start_paused = true)]
1237    async fn request_path_overload_expires_without_monitor_update() {
1238        let rt = Runtime::from_current().unwrap();
1239        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1240            .await
1241            .unwrap();
1242        let client = drt
1243            .namespace("test_request_overload_expiry".to_string())
1244            .unwrap()
1245            .component("test_component".to_string())
1246            .unwrap()
1247            .endpoint("decode".to_string())
1248            .client()
1249            .await
1250            .unwrap();
1251
1252        client.mark_overloaded_immediate(7);
1253        assert_eq!(client.overloaded_instance_ids(), Some(HashSet::from([7])));
1254
1255        tokio::time::advance(REQUEST_PATH_OVERLOAD_COOLDOWN).await;
1256
1257        assert_eq!(client.overloaded_instance_ids(), None);
1258        assert!(!client.overload_reconciliation_needed());
1259        rt.shutdown();
1260    }
1261
1262    #[tokio::test]
1263    async fn request_path_expiry_preserves_monitor_overload() {
1264        let rt = Runtime::from_current().unwrap();
1265        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1266            .await
1267            .unwrap();
1268        let client = drt
1269            .namespace("test_request_expiry_preserves_monitor".to_string())
1270            .unwrap()
1271            .component("test_component".to_string())
1272            .unwrap()
1273            .endpoint("decode".to_string())
1274            .client()
1275            .await
1276            .unwrap();
1277        let now = Instant::now();
1278
1279        assert!(client.set_overloaded_instances(&[11]));
1280        client
1281            .routing_instances
1282            .mark_overloaded_until(7, now + Duration::from_secs(1));
1283        client
1284            .routing_instances
1285            .mark_overloaded_until(8, now + Duration::from_secs(2));
1286
1287        client
1288            .routing_instances
1289            .reconcile_expired_request_overloads(now + Duration::from_millis(1500));
1290
1291        assert_eq!(
1292            client.overloaded_instance_ids(),
1293            Some(HashSet::from([8, 11]))
1294        );
1295        assert!(client.overload_reconciliation_needed());
1296        rt.shutdown();
1297    }
1298
1299    #[tokio::test]
1300    async fn test_instance_reconciliation_preserves_overloaded_existing_instances() {
1301        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1302
1303        let rt = Runtime::from_current().unwrap();
1304        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1305            .await
1306            .unwrap();
1307        let ns = drt
1308            .namespace("test_overloaded_reconciliation".to_string())
1309            .unwrap();
1310        let component = ns.component("test_component".to_string()).unwrap();
1311        let endpoint = component.endpoint("test_endpoint".to_string());
1312
1313        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1314            .await
1315            .unwrap();
1316        endpoint.register_endpoint_instance().await.unwrap();
1317        let instances = client.wait_for_instances().await.unwrap();
1318        let worker_id = instances[0].id();
1319
1320        for _ in 0..10 {
1321            if client.instance_ids_free().contains(&worker_id) {
1322                break;
1323            }
1324            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1325        }
1326        assert!(
1327            client.instance_ids_free().contains(&worker_id),
1328            "worker should be free after initial discovery reconciliation"
1329        );
1330
1331        client.set_overloaded_instances(&[worker_id]);
1332        assert!(
1333            client.instance_ids_free().is_empty(),
1334            "worker should be overloaded before periodic reconciliation"
1335        );
1336
1337        tokio::time::sleep(TEST_RECONCILE_INTERVAL + Duration::from_millis(50)).await;
1338
1339        assert!(
1340            client.instance_ids_free().is_empty(),
1341            "periodic reconciliation should not mark an existing overloaded worker free"
1342        );
1343
1344        rt.shutdown();
1345    }
1346
1347    #[tokio::test]
1348    async fn test_report_instance_down_preserves_overloaded_state() {
1349        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1350
1351        let rt = Runtime::from_current().unwrap();
1352        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1353            .await
1354            .unwrap();
1355        let ns = drt
1356            .namespace("test_report_down_preserves_overloaded".to_string())
1357            .unwrap();
1358        let component = ns.component("test_component".to_string()).unwrap();
1359        let endpoint = component.endpoint("test_endpoint".to_string());
1360
1361        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1362            .await
1363            .unwrap();
1364        endpoint.register_endpoint_instance().await.unwrap();
1365        let instances = client.wait_for_instances().await.unwrap();
1366        let worker_id = instances[0].id();
1367
1368        for _ in 0..10 {
1369            if client.instance_ids_avail().contains(&worker_id) {
1370                break;
1371            }
1372            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1373        }
1374
1375        client.set_overloaded_instances(&[worker_id]);
1376        client.report_instance_down(worker_id);
1377
1378        assert!(
1379            !client.instance_ids_avail().contains(&worker_id),
1380            "reported-down worker should leave routable availability"
1381        );
1382        assert_eq!(
1383            client.routing_instance_counts().overloaded,
1384            1,
1385            "reported-down worker should remain overloaded while still discovered"
1386        );
1387        assert!(
1388            client.instance_ids_free().is_empty(),
1389            "reported-down overloaded worker should not become free"
1390        );
1391
1392        endpoint.unregister_endpoint_instance().await.unwrap();
1393        for _ in 0..10 {
1394            if client.routing_instance_counts().overloaded == 0 {
1395                break;
1396            }
1397            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1398        }
1399
1400        assert_eq!(
1401            client.routing_instance_counts().overloaded,
1402            0,
1403            "stable discovery removal should clear overloaded state"
1404        );
1405
1406        rt.shutdown();
1407    }
1408
1409    #[tokio::test]
1410    async fn test_instance_reconciliation_prunes_removed_overloaded_instances() {
1411        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1412
1413        let rt = Runtime::from_current().unwrap();
1414        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1415            .await
1416            .unwrap();
1417        let ns = drt
1418            .namespace("test_removed_overloaded_cleanup".to_string())
1419            .unwrap();
1420        let component = ns.component("test_component".to_string()).unwrap();
1421        let endpoint = component.endpoint("test_endpoint".to_string());
1422
1423        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1424            .await
1425            .unwrap();
1426        endpoint.register_endpoint_instance().await.unwrap();
1427        let instances = client.wait_for_instances().await.unwrap();
1428        let worker_id = instances[0].id();
1429
1430        client.set_overloaded_instances(&[worker_id]);
1431        assert_eq!(client.routing_instance_counts().overloaded, 1);
1432        assert!(client.instance_ids_free().is_empty());
1433
1434        endpoint.unregister_endpoint_instance().await.unwrap();
1435        for _ in 0..10 {
1436            if client.routing_instance_counts().overloaded == 0 {
1437                break;
1438            }
1439            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1440        }
1441
1442        assert_eq!(
1443            client.routing_instance_counts().overloaded,
1444            0,
1445            "removed discovered workers should not remain in overloaded state"
1446        );
1447
1448        rt.shutdown();
1449    }
1450
1451    #[tokio::test]
1452    async fn test_instance_ids_free_excludes_overloaded_new_instances() {
1453        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1454
1455        let rt = Runtime::from_current().unwrap();
1456        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1457            .await
1458            .unwrap();
1459        let worker_id = drt.connection_id();
1460        let ns = drt
1461            .namespace("test_new_overloaded_reconciliation".to_string())
1462            .unwrap();
1463        let component = ns.component("test_component".to_string()).unwrap();
1464        let endpoint = component.endpoint("test_endpoint".to_string());
1465
1466        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1467            .await
1468            .unwrap();
1469        client.set_overloaded_instances(&[worker_id]);
1470
1471        endpoint.register_endpoint_instance().await.unwrap();
1472        let instances = client.wait_for_instances().await.unwrap();
1473        assert_eq!(instances[0].id(), worker_id);
1474        assert!(
1475            client.instance_ids_free().is_empty(),
1476            "newly discovered overloaded worker should not be free"
1477        );
1478
1479        tokio::time::sleep(TEST_RECONCILE_INTERVAL + Duration::from_millis(50)).await;
1480
1481        assert!(
1482            client.instance_ids_free().is_empty(),
1483            "discovery reconciliation should not affect recomputed free workers"
1484        );
1485
1486        rt.shutdown();
1487    }
1488
1489    #[tokio::test]
1490    async fn test_discovery_add_updates_free_without_overloaded_publish() {
1491        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1492
1493        let rt = Runtime::from_current().unwrap();
1494        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1495            .await
1496            .unwrap();
1497        let ns = drt
1498            .namespace("test_free_updates_on_discovery_add".to_string())
1499            .unwrap();
1500        let component = ns.component("test_component".to_string()).unwrap();
1501        let endpoint = component.endpoint("test_endpoint".to_string());
1502
1503        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1504            .await
1505            .unwrap();
1506        endpoint.register_endpoint_instance().await.unwrap();
1507        let instances = client.wait_for_instances().await.unwrap();
1508        let worker_id = instances[0].id();
1509
1510        for _ in 0..10 {
1511            if client.instance_ids_free().contains(&worker_id) {
1512                break;
1513            }
1514            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1515        }
1516
1517        assert_eq!(
1518            client.instance_ids_free(),
1519            vec![worker_id],
1520            "newly discovered non-overloaded workers should appear free without an overload update"
1521        );
1522
1523        rt.shutdown();
1524    }
1525
1526    /// Test that instance_avail_watcher receives updates when instances change.
1527    #[tokio::test]
1528    async fn test_instance_avail_watcher() {
1529        let rt = Runtime::from_current().unwrap();
1530        // Use process_local config to avoid needing etcd/nats
1531        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1532            .await
1533            .unwrap();
1534        let ns = drt.namespace("test_watcher".to_string()).unwrap();
1535        let component = ns.component("test_component".to_string()).unwrap();
1536        let endpoint = component.endpoint("test_endpoint".to_string());
1537
1538        let client = endpoint.client().await.unwrap();
1539        let watcher = client.instance_avail_watcher();
1540
1541        // Set initial instances
1542        client.override_instance_avail(vec![1, 2, 3]);
1543
1544        // Report instance down - this should notify the watcher
1545        client.report_instance_down(2);
1546
1547        // The watcher should receive the update
1548        // Note: We need to check if changed() was signaled
1549        let current = watcher.borrow().clone();
1550        assert_eq!(current, vec![1, 3]);
1551
1552        rt.shutdown();
1553    }
1554
1555    /// Regression test: `monitor_instance_source_with_cancellation`'s task must
1556    /// exit on its own `cancel_token`, not only at process shutdown.
1557    ///
1558    /// `Client::new` bound this task to the process-wide primary token
1559    /// unconditionally. A caller building a `Client` scoped to something
1560    /// narrower — a monitor bound to one `WorkerSet`'s lifecycle, say — had no
1561    /// way to stop the task before then: dropping every `Client` handle does
1562    /// not stop it, since it holds its own clone. Every WorkerSet rebuild
1563    /// leaked one.
1564    ///
1565    /// The observable is the strong count of `routing_instances`: the spawned
1566    /// task captures a clone of it, so the count returning to 1 proves the
1567    /// task actually exited and dropped that capture.
1568    #[tokio::test]
1569    async fn monitor_instance_source_exits_on_its_own_cancellation_token() {
1570        let rt = Runtime::from_current().unwrap();
1571        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1572            .await
1573            .unwrap();
1574        let ns = drt
1575            .namespace("test_monitor_instance_source_cancellation".to_string())
1576            .unwrap();
1577        let component = ns.component("test_component".to_string()).unwrap();
1578        let endpoint = component.endpoint("test_endpoint".to_string());
1579
1580        let cancel_token = tokio_util::sync::CancellationToken::new();
1581        let client = Client::with_cancellation(endpoint.clone(), cancel_token.clone())
1582            .await
1583            .unwrap();
1584
1585        // Negative control, first: the task must still be alive, and still
1586        // holding its capture, before cancellation.
1587        tokio::time::sleep(Duration::from_millis(200)).await;
1588        assert!(
1589            Arc::strong_count(&client.routing_instances) > 1,
1590            "monitor task must be running (and holding its capture) before cancellation"
1591        );
1592
1593        cancel_token.cancel();
1594        tokio::time::timeout(Duration::from_secs(5), async {
1595            while Arc::strong_count(&client.routing_instances) > 1 {
1596                tokio::time::sleep(Duration::from_millis(10)).await;
1597            }
1598        })
1599        .await
1600        .expect("monitor_instance_source task must exit when its cancel_token cancels");
1601
1602        rt.shutdown();
1603    }
1604
1605    #[tokio::test]
1606    async fn admitted_client_never_routes_unadmitted_endpoint_instances() {
1607        let rt = Runtime::from_current().unwrap();
1608        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1609            .await
1610            .unwrap();
1611        let ns = drt.namespace("test_admitted_client".to_string()).unwrap();
1612        let component = ns.component("test_component".to_string()).unwrap();
1613        let endpoint = component.endpoint("test_endpoint".to_string());
1614        let endpoint_client = endpoint.client().await.unwrap();
1615        endpoint.register_endpoint_instance().await.unwrap();
1616        let worker_id = endpoint_client.wait_for_instances().await.unwrap()[0].id();
1617
1618        let (admission_tx, admission_rx) = tokio::sync::watch::channel(Vec::new());
1619        let admitted_client = endpoint_client.with_admitted_instances(admission_rx);
1620        let mut admitted = admitted_client.instance_avail_watcher();
1621        assert!(admitted.borrow().is_empty());
1622
1623        admission_tx.send_replace(vec![worker_id]);
1624        tokio::time::timeout(Duration::from_secs(1), admitted.changed())
1625            .await
1626            .unwrap()
1627            .unwrap();
1628        assert_eq!(admitted.borrow_and_update().clone(), vec![worker_id]);
1629
1630        admission_tx.send_replace(Vec::new());
1631        tokio::time::timeout(Duration::from_secs(1), admitted.changed())
1632            .await
1633            .unwrap()
1634            .unwrap();
1635        assert!(admitted.borrow_and_update().is_empty());
1636
1637        rt.shutdown();
1638    }
1639
1640    /// Test that concurrent select_and_increment distributes load correctly.
1641    #[tokio::test]
1642    async fn test_concurrent_select_and_increment() {
1643        let state = Arc::new(RoutingOccupancyState::default());
1644        let instance_ids: Vec<u64> = vec![100, 200, 300];
1645        let num_requests = 90;
1646
1647        let mut handles = Vec::new();
1648        for _ in 0..num_requests {
1649            let state = state.clone();
1650            let ids = instance_ids.clone();
1651            handles.push(tokio::spawn(async move {
1652                state.select_exact_min_and_increment(&ids).await
1653            }));
1654        }
1655
1656        for handle in handles {
1657            handle.await.unwrap();
1658        }
1659
1660        assert_eq!(state.load(100), 30);
1661        assert_eq!(state.load(200), 30);
1662        assert_eq!(state.load(300), 30);
1663    }
1664
1665    #[tokio::test]
1666    async fn test_select_exact_min_and_increment_randomizes_ties() {
1667        let mut selected = [false; 3];
1668
1669        for _ in 0..120 {
1670            let state = RoutingOccupancyState::default();
1671            let picked = state
1672                .select_exact_min_and_increment(&[10, 20, 30])
1673                .await
1674                .unwrap();
1675            match picked {
1676                10 => selected[0] = true,
1677                20 => selected[1] = true,
1678                30 => selected[2] = true,
1679                _ => panic!("unexpected worker id: {picked}"),
1680            }
1681        }
1682
1683        let selected_count = selected.into_iter().filter(|seen| *seen).count();
1684        assert!(
1685            selected_count > 1,
1686            "tie-breaking should not always select the first minimum-load worker"
1687        );
1688    }
1689
1690    #[tokio::test]
1691    async fn test_connection_counts() {
1692        let rt = Runtime::from_current().unwrap();
1693        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1694            .await
1695            .unwrap();
1696        let ns = drt.namespace("test_ll_counts".to_string()).unwrap();
1697        let component = ns.component("test_component".to_string()).unwrap();
1698        let endpoint = component.endpoint("test_endpoint".to_string());
1699
1700        let state1 = get_or_create_routing_occupancy_state(&endpoint).await;
1701        let state2 = get_or_create_routing_occupancy_state(&endpoint).await;
1702
1703        let picked1 = state1
1704            .select_exact_min_and_increment(&[10, 20, 30])
1705            .await
1706            .unwrap();
1707        assert_eq!(state1.load(picked1), 1);
1708
1709        let picked2 = state1
1710            .select_exact_min_and_increment(&[10, 20, 30])
1711            .await
1712            .unwrap();
1713        assert_ne!(picked1, picked2);
1714
1715        // state2 should see the same counts (same underlying Arc)
1716        assert_eq!(state2.load(10), state1.load(10));
1717        assert_eq!(state2.load(20), state1.load(20));
1718        assert_eq!(state2.load(30), state1.load(30));
1719
1720        state2.decrement(picked1);
1721        assert_eq!(state1.load(picked1), if picked1 == picked2 { 1 } else { 0 });
1722
1723        rt.shutdown();
1724    }
1725
1726    #[tokio::test]
1727    async fn test_least_loaded_state_retain_preserves_live_counts() {
1728        let state = RoutingOccupancyState::default();
1729
1730        // Add some connections
1731        state.select_exact_min_and_increment(&[1, 2, 3]).await;
1732        state.select_exact_min_and_increment(&[1, 2, 3]).await;
1733        state.select_exact_min_and_increment(&[1, 2, 3]).await;
1734        // Each instance should have 1 connection
1735        assert_eq!(state.load(1), 1);
1736        assert_eq!(state.load(2), 1);
1737        assert_eq!(state.load(3), 1);
1738
1739        // Discovery removal must not delete guard-owned accounting.
1740        state.retain(&[1, 3]);
1741
1742        assert_eq!(state.load(1), 1);
1743        assert_eq!(state.load(2), 1);
1744        assert_eq!(state.load(3), 1);
1745    }
1746
1747    #[tokio::test]
1748    async fn test_monitor_instance_source_defers_removed_worker_cleanup() {
1749        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1750
1751        let rt = Runtime::from_current().unwrap();
1752        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1753            .await
1754            .unwrap();
1755        let ns = drt.namespace("test_occupancy_cleanup".to_string()).unwrap();
1756        let component = ns.component("test_component".to_string()).unwrap();
1757        let endpoint = component.endpoint("test_endpoint".to_string());
1758
1759        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1760            .await
1761            .unwrap();
1762        endpoint.register_endpoint_instance().await.unwrap();
1763        client.wait_for_instances().await.unwrap();
1764
1765        let worker_id = client.instance_ids_avail()[0];
1766        let state = get_or_create_routing_occupancy_state(&endpoint).await;
1767        state.increment(worker_id);
1768        assert_eq!(state.load(worker_id), 1);
1769
1770        endpoint.unregister_endpoint_instance().await.unwrap();
1771
1772        for _ in 0..10 {
1773            if !client.instance_ids().contains(&worker_id) {
1774                break;
1775            }
1776            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1777        }
1778
1779        assert_eq!(
1780            state.load(worker_id),
1781            1,
1782            "discovery absence must retain live accounting"
1783        );
1784        state.decrement(worker_id);
1785        assert_eq!(state.load(worker_id), 0);
1786
1787        rt.shutdown();
1788    }
1789}