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::sync::atomic::{AtomicBool, AtomicU64, Ordering};
5use std::{
6    collections::{HashMap, HashSet},
7    sync::{Arc, LazyLock, Mutex as StdMutex},
8    time::Duration,
9};
10
11use anyhow::Result;
12use arc_swap::ArcSwap;
13use dashmap::DashMap;
14use futures::StreamExt;
15use rand::Rng;
16
17use crate::component::{Endpoint, Instance};
18use crate::config::environment_names::runtime as env_runtime;
19use crate::discovery::{DiscoveryEvent, DiscoveryInstance, DiscoveryInstanceId};
20use crate::traits::DistributedRuntimeProvider;
21
22/// Shared occupancy state for routing modes that track per-worker in-flight requests.
23#[derive(Debug, Default)]
24pub(crate) struct RoutingOccupancyState {
25    counts: DashMap<u64, AtomicU64>,
26    exact_selection_lock: tokio::sync::Mutex<()>,
27}
28
29impl RoutingOccupancyState {
30    pub(crate) fn increment(&self, instance_id: u64) {
31        self.counts
32            .entry(instance_id)
33            .or_insert_with(|| AtomicU64::new(0))
34            .fetch_add(1, Ordering::Relaxed);
35    }
36
37    pub(crate) async fn select_exact_min(&self, instance_ids: &[u64]) -> Option<u64> {
38        instance_ids
39            .iter()
40            .min_by_key(|&&id| self.load(id))
41            .copied()
42    }
43
44    pub(crate) async fn select_exact_min_and_increment(&self, instance_ids: &[u64]) -> Option<u64> {
45        let _guard = self.exact_selection_lock.lock().await;
46
47        let mut min_load = u64::MAX;
48        let mut selected = None;
49        let mut tie_count = 0usize;
50        let mut rng = rand::rng();
51        for &id in instance_ids {
52            let load = self.load(id);
53            if load < min_load {
54                min_load = load;
55                selected = Some(id);
56                tie_count = 1;
57                continue;
58            }
59
60            if load == min_load {
61                tie_count += 1;
62                // Reservoir sampling keeps tied minima uniform without allocating in this locked hot path.
63                if rng.random_range(0..tie_count) == 0 {
64                    selected = Some(id);
65                }
66            }
67        }
68
69        let id = selected?;
70        self.increment(id);
71        Some(id)
72    }
73
74    /// Least-loaded selection without the increment. Same tie-break policy as
75    /// [`Self::select_exact_min_and_increment`] so peek and select share a
76    /// distribution.
77    pub(crate) fn peek_min(&self, instance_ids: &[u64]) -> Option<u64> {
78        let mut min_load = u64::MAX;
79        let mut selected = None;
80        let mut tie_count = 0usize;
81        let mut rng = rand::rng();
82        for &id in instance_ids {
83            let load = self.load(id);
84            if load < min_load {
85                min_load = load;
86                selected = Some(id);
87                tie_count = 1;
88                continue;
89            }
90
91            if load == min_load {
92                tie_count += 1;
93                // Reservoir sampling keeps tied minima uniform; matches select_exact_min_and_increment.
94                if rng.random_range(0..tie_count) == 0 {
95                    selected = Some(id);
96                }
97            }
98        }
99
100        selected
101    }
102
103    pub(crate) fn decrement(&self, instance_id: u64) {
104        if let Some(count) = self.counts.get(&instance_id) {
105            let _ = count.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
106                Some(current.saturating_sub(1))
107            });
108        }
109    }
110
111    pub(crate) fn load(&self, instance_id: u64) -> u64 {
112        self.counts
113            .get(&instance_id)
114            .map(|c| c.load(Ordering::Relaxed))
115            .unwrap_or(0)
116    }
117
118    pub(crate) fn retain(&self, instance_ids: &[u64]) {
119        let live: HashSet<u64> = instance_ids.iter().copied().collect();
120        self.counts.retain(|id, _| live.contains(id));
121    }
122}
123
124/// Get or create the shared routing occupancy state for an endpoint.
125pub(crate) async fn get_or_create_routing_occupancy_state(
126    endpoint: &Endpoint,
127) -> Arc<RoutingOccupancyState> {
128    let drt = endpoint.drt();
129    let registry = drt.routing_occupancy_states();
130    let mut registry = registry.lock().await;
131
132    if let Some(weak) = registry.get(endpoint) {
133        if let Some(state) = weak.upgrade() {
134            return state;
135        } else {
136            registry.remove(endpoint);
137        }
138    }
139
140    let state = Arc::new(RoutingOccupancyState::default());
141    registry.insert(endpoint.clone(), Arc::downgrade(&state));
142    state
143}
144
145/// Default interval for periodic reconciliation of instance_avail with instance_source
146const DEFAULT_INHIBITED_DURATION_SECS: u64 = 5;
147
148/// Process-wide inhibited duration, resolved from the environment on first client construction.
149static INHIBITED_DURATION: LazyLock<Duration> =
150    LazyLock::new(|| inhibited_duration_from_env(|name| std::env::var(name).ok()));
151
152fn inhibited_duration_from_env(mut lookup: impl FnMut(&str) -> Option<String>) -> Duration {
153    let seconds = match lookup(env_runtime::DYN_RUNTIME_INHIBITED_DURATION_SECS) {
154        None => DEFAULT_INHIBITED_DURATION_SECS,
155        Some(raw) => match raw.parse::<u64>() {
156            Ok(seconds) => seconds,
157            Err(err) => {
158                tracing::warn!(
159                    value = raw,
160                    %err,
161                    "invalid {}; using the default of {} seconds",
162                    env_runtime::DYN_RUNTIME_INHIBITED_DURATION_SECS,
163                    DEFAULT_INHIBITED_DURATION_SECS,
164                );
165                DEFAULT_INHIBITED_DURATION_SECS
166            }
167        },
168    };
169    Duration::from_secs(seconds)
170}
171
172/// Shared endpoint discovery state for a single endpoint query.
173///
174/// This wraps both the coalesced instance snapshot used for routing decisions
175/// and a raw, lossless per-subscriber event feed used by the response-stream
176/// cancellation watcher. Both outputs are driven by a single underlying
177/// discovery `list_and_watch` task so clients do not multiply control-plane
178/// watches.
179#[derive(Debug)]
180pub(crate) struct EndpointDiscoverySource {
181    instance_source: tokio::sync::watch::Receiver<Vec<Instance>>,
182    event_subscribers: StdMutex<Vec<tokio::sync::mpsc::UnboundedSender<DiscoveryEvent>>>,
183}
184
185pub(crate) struct DiscoveryEventReceiver {
186    receiver: tokio::sync::mpsc::UnboundedReceiver<DiscoveryEvent>,
187    _source: Arc<EndpointDiscoverySource>,
188}
189
190impl std::ops::Deref for DiscoveryEventReceiver {
191    type Target = tokio::sync::mpsc::UnboundedReceiver<DiscoveryEvent>;
192
193    fn deref(&self) -> &Self::Target {
194        &self.receiver
195    }
196}
197
198impl std::ops::DerefMut for DiscoveryEventReceiver {
199    fn deref_mut(&mut self) -> &mut Self::Target {
200        &mut self.receiver
201    }
202}
203
204impl EndpointDiscoverySource {
205    fn new(instance_source: tokio::sync::watch::Receiver<Vec<Instance>>) -> Self {
206        Self {
207            instance_source,
208            event_subscribers: StdMutex::new(Vec::new()),
209        }
210    }
211
212    fn instance_receiver(&self) -> tokio::sync::watch::Receiver<Vec<Instance>> {
213        self.instance_source.clone()
214    }
215
216    fn subscribe_events(&self) -> tokio::sync::mpsc::UnboundedReceiver<DiscoveryEvent> {
217        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
218        self.event_subscribers.lock().unwrap().push(tx);
219        rx
220    }
221
222    fn broadcast_event(&self, event: &DiscoveryEvent) {
223        let subscribers = &mut *self.event_subscribers.lock().unwrap();
224        subscribers.retain(|tx| tx.send(event.clone()).is_ok());
225    }
226}
227
228#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
229pub struct RoutingInstanceCounts {
230    pub discovered: usize,
231    pub routable: usize,
232    pub overloaded: usize,
233    /// IDs not currently reported overloaded, derived from `discovered - overloaded`.
234    pub free: usize,
235}
236
237#[derive(Clone, Debug)]
238pub(crate) struct RoutingInstances {
239    discovered_ids: Vec<u64>,
240    routable_ids: Vec<u64>,
241    overloaded_ids: HashSet<u64>,
242    free_ids: Vec<u64>,
243}
244
245impl RoutingInstances {
246    fn new(discovered_ids: Vec<u64>) -> Self {
247        Self::from_parts(discovered_ids.clone(), discovered_ids, HashSet::new())
248    }
249
250    fn from_parts(
251        discovered_ids: Vec<u64>,
252        routable_ids: Vec<u64>,
253        overloaded_ids: HashSet<u64>,
254    ) -> Self {
255        let free_ids = Self::derive_free_ids(&routable_ids, &overloaded_ids);
256        Self {
257            discovered_ids,
258            routable_ids,
259            overloaded_ids,
260            free_ids,
261        }
262    }
263
264    pub(crate) fn discovered_ids(&self) -> &[u64] {
265        &self.discovered_ids
266    }
267
268    pub(crate) fn routable_ids(&self) -> &[u64] {
269        &self.routable_ids
270    }
271
272    pub(crate) fn free_ids(&self) -> &[u64] {
273        &self.free_ids
274    }
275
276    pub(crate) fn counts(&self) -> RoutingInstanceCounts {
277        RoutingInstanceCounts {
278            discovered: self.discovered_ids.len(),
279            routable: self.routable_ids.len(),
280            overloaded: self.overloaded_ids.len(),
281            free: self.free_ids.len(),
282        }
283    }
284
285    pub(crate) fn is_overloaded(&self, instance_id: u64) -> bool {
286        self.overloaded_ids.contains(&instance_id)
287    }
288
289    fn overloaded_ids(&self) -> Option<HashSet<u64>> {
290        if self.overloaded_ids.is_empty() {
291            return None;
292        }
293
294        Some(self.overloaded_ids.clone())
295    }
296
297    fn reconcile_discovered(&self, discovered_ids: Vec<u64>) -> Self {
298        let old_discovered_ids = self.discovered_ids.iter().copied().collect::<HashSet<_>>();
299        let new_discovered_ids = discovered_ids.iter().copied().collect::<HashSet<_>>();
300        let mut overloaded_ids = self.overloaded_ids.clone();
301        overloaded_ids
302            .retain(|id| !old_discovered_ids.contains(id) || new_discovered_ids.contains(id));
303
304        Self::from_parts(discovered_ids.clone(), discovered_ids, overloaded_ids)
305    }
306
307    fn report_instance_down(&self, instance_id: u64) -> Self {
308        let routable_ids: Vec<u64> = self
309            .routable_ids
310            .iter()
311            .copied()
312            .filter(|id| *id != instance_id)
313            .collect();
314
315        Self::from_parts(
316            self.discovered_ids.clone(),
317            routable_ids,
318            self.overloaded_ids.clone(),
319        )
320    }
321
322    #[cfg(test)]
323    fn override_routable_ids(&self, routable_ids: Vec<u64>) -> Self {
324        // Route through from_parts so `free_ids` is recomputed from the new
325        // routable set instead of carrying the stale value forward.
326        Self::from_parts(
327            self.discovered_ids.clone(),
328            routable_ids,
329            self.overloaded_ids.clone(),
330        )
331    }
332
333    fn set_overloaded(&self, overloaded_ids: HashSet<u64>) -> Self {
334        Self::from_parts(
335            self.discovered_ids.clone(),
336            self.routable_ids.clone(),
337            overloaded_ids,
338        )
339    }
340
341    /// Add a single instance to the overloaded set (immediate
342    /// backpressure mark). Short-lived: the next metric-driven
343    /// `set_overloaded` recompute overwrites the whole set.
344    fn mark_overloaded(&self, instance_id: u64) -> Self {
345        let mut overloaded_ids = self.overloaded_ids.clone();
346        overloaded_ids.insert(instance_id);
347        Self::from_parts(
348            self.discovered_ids.clone(),
349            self.routable_ids.clone(),
350            overloaded_ids,
351        )
352    }
353
354    fn clear_overloaded_for_removed(&self, removed_ids: &HashSet<u64>) -> Self {
355        let mut overloaded_ids = self.overloaded_ids.clone();
356        overloaded_ids.retain(|id| !removed_ids.contains(id));
357        Self::from_parts(
358            self.discovered_ids.clone(),
359            self.routable_ids.clone(),
360            overloaded_ids,
361        )
362    }
363
364    fn derive_free_ids(routable_ids: &[u64], overloaded_ids: &HashSet<u64>) -> Vec<u64> {
365        if overloaded_ids.is_empty() {
366            return routable_ids.to_vec();
367        }
368
369        routable_ids
370            .iter()
371            .copied()
372            .filter(|id| !overloaded_ids.contains(id))
373            .collect()
374    }
375}
376
377#[derive(Debug)]
378struct RoutingInstancesState {
379    snapshot: ArcSwap<RoutingInstances>,
380    update_lock: StdMutex<()>,
381    overload_reconciliation_needed: AtomicBool,
382    instance_avail_tx: tokio::sync::watch::Sender<Vec<u64>>,
383}
384
385impl RoutingInstancesState {
386    fn new(discovered_ids: Vec<u64>) -> (Self, tokio::sync::watch::Receiver<Vec<u64>>) {
387        let snapshot = RoutingInstances::new(discovered_ids);
388        let (instance_avail_tx, instance_avail_rx) =
389            tokio::sync::watch::channel(snapshot.routable_ids().to_vec());
390        (
391            Self {
392                snapshot: ArcSwap::from_pointee(snapshot),
393                update_lock: StdMutex::new(()),
394                overload_reconciliation_needed: AtomicBool::new(false),
395                instance_avail_tx,
396            },
397            instance_avail_rx,
398        )
399    }
400
401    fn snapshot(&self) -> arc_swap::Guard<Arc<RoutingInstances>> {
402        self.snapshot.load()
403    }
404
405    fn update(
406        &self,
407        update: impl FnOnce(&RoutingInstances) -> RoutingInstances,
408        publish_routable_ids: bool,
409    ) -> Arc<RoutingInstances> {
410        let _guard = self.update_lock.lock().unwrap();
411        let current = self.snapshot.load();
412        let next = Arc::new(update(&current));
413        self.snapshot.store(next.clone());
414        if publish_routable_ids {
415            self.publish_routable_ids(&next);
416        }
417        next
418    }
419
420    fn publish_routable_ids(&self, routing_instances: &RoutingInstances) {
421        let _ = self
422            .instance_avail_tx
423            .send(routing_instances.routable_ids().to_vec());
424    }
425
426    fn routable_ids(&self) -> Vec<u64> {
427        self.snapshot().routable_ids().to_vec()
428    }
429
430    fn free_ids(&self) -> Vec<u64> {
431        self.snapshot().free_ids.clone()
432    }
433
434    fn counts(&self) -> RoutingInstanceCounts {
435        self.snapshot().counts()
436    }
437
438    fn overloaded_ids(&self) -> Option<HashSet<u64>> {
439        self.snapshot().overloaded_ids()
440    }
441
442    fn report_instance_down(&self, instance_id: u64) {
443        self.update(|current| current.report_instance_down(instance_id), true);
444    }
445
446    fn set_overloaded_instances(&self, overloaded_instance_ids: &[u64]) -> bool {
447        let overloaded_ids = overloaded_instance_ids
448            .iter()
449            .copied()
450            .collect::<HashSet<_>>();
451        let _guard = self.update_lock.lock().unwrap();
452        self.overload_reconciliation_needed
453            .store(false, Ordering::Release);
454        let current = self.snapshot.load();
455        if current.overloaded_ids == overloaded_ids {
456            return false;
457        }
458
459        let next = Arc::new(current.set_overloaded(overloaded_ids));
460        self.snapshot.store(next);
461        true
462    }
463
464    fn mark_overloaded_immediate(&self, instance_id: u64) {
465        let _guard = self.update_lock.lock().unwrap();
466        let current = self.snapshot.load();
467        let next = Arc::new(current.mark_overloaded(instance_id));
468        self.snapshot.store(next);
469        self.overload_reconciliation_needed
470            .store(true, Ordering::Release);
471    }
472
473    fn overload_reconciliation_needed(&self) -> bool {
474        self.overload_reconciliation_needed.load(Ordering::Acquire)
475    }
476
477    fn clear_overloaded_for_removed(&self, removed_instance_ids: &[u64]) {
478        if removed_instance_ids.is_empty() {
479            return;
480        }
481
482        let removed_ids = removed_instance_ids.iter().copied().collect::<HashSet<_>>();
483        self.update(
484            move |current| current.clear_overloaded_for_removed(&removed_ids),
485            false,
486        );
487    }
488
489    fn reconcile_discovered(&self, discovered_ids: Vec<u64>) -> Arc<RoutingInstances> {
490        self.update(
491            move |current| current.reconcile_discovered(discovered_ids),
492            true,
493        )
494    }
495
496    #[cfg(test)]
497    fn override_routable_ids(&self, ids: Vec<u64>) {
498        self.update(move |current| current.override_routable_ids(ids), true);
499    }
500}
501
502#[derive(Clone, Debug)]
503pub struct Client {
504    // This is me
505    pub endpoint: Endpoint,
506    // Shared endpoint discovery source backing both snapshots and raw events.
507    endpoint_discovery_source: Arc<EndpointDiscoverySource>,
508    // These are the remotes I know about from watching key-value store
509    pub instance_source: Arc<tokio::sync::watch::Receiver<Vec<Instance>>>,
510    // Immutable routing snapshot. Free IDs are derived from discovered IDs and overloaded IDs.
511    routing_instances: Arc<RoutingInstancesState>,
512    // Client clones and standalone watchers jointly own the reconciliation task.
513    instance_avail_owner: Arc<tokio::sync::watch::Receiver<Vec<u64>>>,
514    /// Interval for periodic reconciliation of instance_avail with instance_source.
515    /// This ensures instances removed via `report_instance_down` are eventually restored.
516    /// A zero value disables local worker inhibition.
517    reconcile_interval: Duration,
518}
519
520impl Client {
521    // Client with auto-discover instances using key-value store
522    pub(crate) async fn new(endpoint: Endpoint) -> Result<Self> {
523        Self::with_reconcile_interval(endpoint, *INHIBITED_DURATION).await
524    }
525
526    /// Create a client with a custom reconcile interval.
527    /// The reconcile interval controls how often `instance_avail` is reset to match
528    /// `instance_source`, restoring any instances removed via `report_instance_down`.
529    pub(crate) async fn with_reconcile_interval(
530        endpoint: Endpoint,
531        reconcile_interval: Duration,
532    ) -> Result<Self> {
533        tracing::trace!(
534            "Client::new_dynamic: Creating dynamic client for endpoint: {}",
535            endpoint.id()
536        );
537        let endpoint_discovery_source =
538            Self::get_or_create_dynamic_discovery_source(&endpoint).await?;
539        let instance_source = Arc::new(endpoint_discovery_source.instance_receiver());
540
541        // Seed instance_avail from the current instance_source snapshot so that
542        // callers who proceed immediately after wait_for_instances (which reads
543        // instance_source directly) will also find instances in instance_avail
544        // (which is read by the routing methods like random/round_robin).
545        let initial_ids: Vec<u64> = instance_source
546            .borrow()
547            .iter()
548            .map(|instance| instance.id())
549            .collect();
550        let (routing_instances, instance_avail_owner) = RoutingInstancesState::new(initial_ids);
551        let client = Client {
552            endpoint: endpoint.clone(),
553            endpoint_discovery_source,
554            instance_source: instance_source.clone(),
555            routing_instances: Arc::new(routing_instances),
556            instance_avail_owner: Arc::new(instance_avail_owner),
557            reconcile_interval,
558        };
559        client.monitor_instance_source();
560        Ok(client)
561    }
562
563    /// Instances available from watching key-value store
564    pub fn instances(&self) -> Vec<Instance> {
565        self.instance_source.borrow().clone()
566    }
567
568    pub fn instance_ids(&self) -> Vec<u64> {
569        self.instances().into_iter().map(|ep| ep.id()).collect()
570    }
571
572    pub fn instance_ids_avail(&self) -> Vec<u64> {
573        self.routing_instances.routable_ids()
574    }
575
576    /// Routable instance ids excluding those currently flagged overloaded — the set used
577    /// for load-aware (random / round-robin) worker selection.
578    pub fn instance_ids_free(&self) -> Vec<u64> {
579        self.routing_instances.free_ids()
580    }
581
582    pub(crate) fn routing_instances(&self) -> arc_swap::Guard<Arc<RoutingInstances>> {
583        self.routing_instances.snapshot()
584    }
585
586    pub fn routing_instance_counts(&self) -> RoutingInstanceCounts {
587        self.routing_instances.counts()
588    }
589
590    /// Get a watcher for available instance IDs
591    pub fn instance_avail_watcher(&self) -> tokio::sync::watch::Receiver<Vec<u64>> {
592        self.instance_avail_owner.as_ref().clone()
593    }
594
595    /// Subscribe to raw discovery events for this endpoint.
596    ///
597    /// Unlike `instance_source`, this feed does not coalesce remove→add pairs,
598    /// so consumers can react to every removal event exactly once.
599    pub(crate) fn subscribe_discovery_events(&self) -> DiscoveryEventReceiver {
600        DiscoveryEventReceiver {
601            receiver: self.endpoint_discovery_source.subscribe_events(),
602            _source: self.endpoint_discovery_source.clone(),
603        }
604    }
605
606    /// Wait for at least one Instance to be available for this Endpoint
607    pub async fn wait_for_instances(&self) -> Result<Vec<Instance>> {
608        tracing::trace!(
609            "wait_for_instances: Starting wait for endpoint: {}",
610            self.endpoint.id()
611        );
612        let mut rx = self.instance_source.as_ref().clone();
613        // wait for there to be 1 or more endpoints
614        let mut instances: Vec<Instance>;
615        loop {
616            instances = rx.borrow_and_update().to_vec();
617            if instances.is_empty() {
618                rx.changed().await?;
619            } else {
620                tracing::info!(
621                    "wait_for_instances: Found {} instance(s) for endpoint: {}",
622                    instances.len(),
623                    self.endpoint.id()
624                );
625                break;
626            }
627        }
628        Ok(instances)
629    }
630
631    /// Mark an instance as down/unavailable
632    pub fn report_instance_down(&self, instance_id: u64) {
633        if self.reconcile_interval.is_zero() {
634            tracing::debug!(
635                instance_id,
636                "local worker inhibition is disabled; leaving instance routable"
637            );
638            return;
639        }
640
641        self.routing_instances.report_instance_down(instance_id);
642        tracing::debug!("inhibiting instance {instance_id}");
643    }
644
645    /// Replace the set of overloaded instances reported by the worker monitor.
646    /// Returns true when this changes the routing snapshot.
647    pub fn set_overloaded_instances(&self, overloaded_instance_ids: &[u64]) -> bool {
648        self.routing_instances
649            .set_overloaded_instances(overloaded_instance_ids)
650    }
651
652    /// Whether request-path backpressure changed overload state after the monitor's
653    /// most recent metric publication.
654    pub fn overload_reconciliation_needed(&self) -> bool {
655        self.routing_instances.overload_reconciliation_needed()
656    }
657
658    /// Mark an instance overloaded immediately. A worker returning
659    /// `ResourceExhausted` is busy ("queue full, retry later"), not faulted, so
660    /// this is the overload path, NOT `report_instance_down`. Short-lived: the
661    /// next `set_overloaded_instances` recompute overwrites the overloaded set.
662    pub fn mark_overloaded_immediate(&self, instance_id: u64) {
663        self.routing_instances
664            .mark_overloaded_immediate(instance_id);
665        tracing::debug!(
666            instance_id,
667            "marking instance overloaded (backpressure); next metric event will re-evaluate"
668        );
669    }
670
671    pub fn clear_overloaded_instances_for_removed(&self, removed_instance_ids: &[u64]) {
672        self.routing_instances
673            .clear_overloaded_for_removed(removed_instance_ids);
674    }
675
676    pub fn overloaded_instance_ids(&self) -> Option<HashSet<u64>> {
677        self.routing_instances.overloaded_ids()
678    }
679
680    /// Monitor the key-value instance source and update instance_avail.
681    ///
682    /// This function also performs periodic reconciliation: if `instance_source` hasn't
683    /// changed for `reconcile_interval`, we reset `instance_avail` to match
684    /// `instance_source`. This ensures instances removed via `report_instance_down`
685    /// are eventually restored even if the discovery source doesn't emit updates.
686    fn monitor_instance_source(&self) {
687        let reconcile_interval = self.reconcile_interval;
688        let cancel_token = self.endpoint.drt().primary_token();
689        let endpoint = self.endpoint.clone();
690        let endpoint_discovery_source = self.endpoint_discovery_source.clone();
691        let routing_instances = self.routing_instances.clone();
692        let instance_source = self.instance_source.clone();
693        let endpoint_id = self.endpoint.id();
694        tokio::task::spawn(async move {
695            let mut rx = instance_source.as_ref().clone();
696            while !cancel_token.is_cancelled() {
697                let instance_ids: Vec<u64> = rx
698                    .borrow_and_update()
699                    .iter()
700                    .map(|instance| instance.id())
701                    .collect();
702
703                let snapshot = routing_instances.reconcile_discovered(instance_ids);
704
705                // Clean up stale occupancy counters for instances that no longer exist.
706                let registry = endpoint.drt().routing_occupancy_states();
707                if let Ok(registry) = registry.try_lock()
708                    && let Some(weak) = registry.get(&endpoint)
709                    && let Some(state) = weak.upgrade()
710                {
711                    state.retain(snapshot.discovered_ids());
712                }
713
714                tokio::select! {
715                    _ = cancel_token.cancelled() => break,
716                    _ = routing_instances.instance_avail_tx.closed() => break,
717                    result = rx.changed() => {
718                        if let Err(err) = result {
719                            tracing::error!(
720                                "monitor_instance_source: The Sender is dropped: {err}, endpoint={endpoint_id}",
721                            );
722                            cancel_token.cancel();
723                        }
724                    }
725                    _ = tokio::time::sleep(reconcile_interval), if !reconcile_interval.is_zero() => {
726                        tracing::trace!(
727                            "monitor_instance_source: periodic reconciliation for endpoint={endpoint_id}",
728                        );
729                    }
730                }
731            }
732            drop(endpoint_discovery_source);
733        });
734    }
735
736    /// Override routable IDs for testing. This allows creating an inconsistency
737    /// between `instance_ids_avail()` and `instances()` to simulate downed workers.
738    #[cfg(test)]
739    pub(crate) fn override_instance_avail(&self, ids: Vec<u64>) {
740        self.routing_instances.override_routable_ids(ids);
741    }
742
743    async fn get_or_create_dynamic_discovery_source(
744        endpoint: &Endpoint,
745    ) -> Result<Arc<EndpointDiscoverySource>> {
746        let drt = endpoint.drt();
747        let sources = drt.endpoint_discovery_sources();
748        let mut sources = sources.lock().await;
749
750        if let Some(source) = sources.get(endpoint) {
751            if let Some(source) = source.upgrade() {
752                return Ok(source);
753            } else {
754                sources.remove(endpoint);
755            }
756        }
757
758        let discovery = drt.discovery();
759        let discovery_query = crate::discovery::DiscoveryQuery::Endpoint {
760            namespace: endpoint.component.namespace.name.clone(),
761            component: endpoint.component.name.clone(),
762            endpoint: endpoint.name.clone(),
763        };
764
765        let mut discovery_stream = discovery
766            .list_and_watch(discovery_query.clone(), None)
767            .await?;
768        let (watch_tx, watch_rx) = tokio::sync::watch::channel(vec![]);
769        let discovery_source = Arc::new(EndpointDiscoverySource::new(watch_rx));
770
771        let secondary = endpoint.component.drt.runtime().secondary().clone();
772        let discovery_source_task = Arc::downgrade(&discovery_source);
773
774        secondary.spawn(async move {
775            tracing::trace!("endpoint_watcher: Starting for discovery query: {:?}", discovery_query);
776            let mut map: HashMap<u64, Instance> = HashMap::new();
777
778            loop {
779                let discovery_event = tokio::select! {
780                    _ = watch_tx.closed() => {
781                        break;
782                    }
783                    discovery_event = discovery_stream.next() => {
784                        match discovery_event {
785                            Some(Ok(event)) => {
786                                event
787                            },
788                            Some(Err(e)) => {
789                                tracing::error!("endpoint_watcher: discovery stream error: {}; shutting down for discovery query: {:?}", e, discovery_query);
790                                break;
791                            }
792                            None => {
793                                break;
794                            }
795                        }
796                    }
797                };
798
799                if let Some(discovery_source) = discovery_source_task.upgrade() {
800                    discovery_source.broadcast_event(&discovery_event);
801                }
802
803                match discovery_event {
804                    DiscoveryEvent::Added(DiscoveryInstance::Endpoint(instance)) => {
805                        map.insert(instance.instance_id, instance);
806                    }
807                    DiscoveryEvent::Added(_) => {}
808                    DiscoveryEvent::Removed(id) => {
809                        if let DiscoveryInstanceId::Endpoint(endpoint_id) = id {
810                            map.remove(&endpoint_id.instance_id);
811                        }
812                    }
813                }
814
815                let instances: Vec<Instance> = map.values().cloned().collect();
816                if watch_tx.send(instances).is_err() {
817                    break;
818                }
819            }
820            let _ = watch_tx.send(vec![]);
821        });
822
823        sources.insert(endpoint.clone(), Arc::downgrade(&discovery_source));
824        Ok(discovery_source)
825    }
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831    use crate::{DistributedRuntime, Runtime, distributed::DistributedConfig};
832
833    async fn wait_for_discovery_event(
834        receiver: &mut DiscoveryEventReceiver,
835        predicate: impl Fn(&DiscoveryEvent) -> bool,
836    ) {
837        tokio::time::timeout(Duration::from_secs(1), async {
838            loop {
839                let event = receiver.recv().await.expect("discovery event feed closed");
840                if predicate(&event) {
841                    return;
842                }
843            }
844        })
845        .await
846        .expect("expected discovery event was not received");
847    }
848
849    async fn wait_for_watch_state<T>(
850        receiver: &mut tokio::sync::watch::Receiver<Vec<T>>,
851        predicate: impl Fn(&[T]) -> bool,
852    ) {
853        tokio::time::timeout(Duration::from_secs(1), async {
854            loop {
855                if predicate(receiver.borrow_and_update().as_slice()) {
856                    return;
857                }
858                receiver
859                    .changed()
860                    .await
861                    .expect("instance availability feed closed");
862            }
863        })
864        .await
865        .expect("expected instance availability state was not observed");
866    }
867
868    #[test]
869    fn test_inhibited_duration_from_env() {
870        assert_eq!(
871            inhibited_duration_from_env(|_| None),
872            Duration::from_secs(DEFAULT_INHIBITED_DURATION_SECS)
873        );
874        assert_eq!(
875            inhibited_duration_from_env(|_| Some("17".to_string())),
876            Duration::from_secs(17)
877        );
878        assert_eq!(
879            inhibited_duration_from_env(|_| Some("0".to_string())),
880            Duration::ZERO
881        );
882        assert_eq!(
883            inhibited_duration_from_env(|_| Some("invalid".to_string())),
884            Duration::from_secs(DEFAULT_INHIBITED_DURATION_SECS)
885        );
886    }
887
888    #[tokio::test]
889    async fn dropping_last_client_releases_routing_state() {
890        let rt = Runtime::from_current().unwrap();
891        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
892            .await
893            .unwrap();
894        let endpoint = drt
895            .namespace("test_client_lifecycle".to_string())
896            .unwrap()
897            .component("test_component".to_string())
898            .unwrap()
899            .endpoint("decode".to_string());
900        let client = endpoint.client().await.unwrap();
901        let routing_instances = Arc::downgrade(&client.routing_instances);
902        let discovery_source = Arc::downgrade(&client.endpoint_discovery_source);
903        let mut raw_watcher = client.instance_source.as_ref().clone();
904        let mut watcher = client.instance_avail_watcher();
905        let mut event_watcher = client.subscribe_discovery_events();
906        raw_watcher.borrow_and_update();
907        watcher.borrow_and_update();
908
909        drop(client);
910        assert!(routing_instances.upgrade().is_some());
911        assert!(discovery_source.upgrade().is_some());
912
913        endpoint.register_endpoint_instance().await.unwrap();
914        wait_for_watch_state(&mut watcher, |instances| instances.len() == 1).await;
915        wait_for_discovery_event(&mut event_watcher, |event| {
916            matches!(event, DiscoveryEvent::Added(DiscoveryInstance::Endpoint(_)))
917        })
918        .await;
919
920        endpoint.unregister_endpoint_instance().await.unwrap();
921        wait_for_watch_state(&mut watcher, |instances| instances.is_empty()).await;
922        assert!(raw_watcher.borrow_and_update().is_empty());
923        wait_for_discovery_event(&mut event_watcher, |event| {
924            matches!(event, DiscoveryEvent::Removed(_))
925        })
926        .await;
927
928        drop(watcher);
929
930        tokio::time::timeout(Duration::from_secs(1), async {
931            while routing_instances.strong_count() != 0 {
932                tokio::task::yield_now().await;
933            }
934        })
935        .await
936        .expect("client monitor retained state after its last observer was dropped");
937        assert!(discovery_source.upgrade().is_some());
938
939        endpoint.register_endpoint_instance().await.unwrap();
940        wait_for_watch_state(&mut raw_watcher, |instances| instances.len() == 1).await;
941        wait_for_discovery_event(&mut event_watcher, |event| {
942            matches!(event, DiscoveryEvent::Added(DiscoveryInstance::Endpoint(_)))
943        })
944        .await;
945        endpoint.unregister_endpoint_instance().await.unwrap();
946        wait_for_watch_state(&mut raw_watcher, |instances| instances.is_empty()).await;
947        wait_for_discovery_event(&mut event_watcher, |event| {
948            matches!(event, DiscoveryEvent::Removed(_))
949        })
950        .await;
951
952        drop(event_watcher);
953        tokio::time::timeout(Duration::from_secs(1), async {
954            while discovery_source.strong_count() != 0 {
955                tokio::task::yield_now().await;
956            }
957        })
958        .await
959        .expect("discovery event receiver retained its source after being dropped");
960
961        rt.shutdown();
962    }
963
964    /// Test that instances removed via report_instance_down are restored after
965    /// the reconciliation interval elapses.
966    #[tokio::test]
967    async fn test_instance_reconciliation() {
968        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(100);
969
970        let rt = Runtime::from_current().unwrap();
971        // Use process_local config to avoid needing etcd/nats
972        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
973            .await
974            .unwrap();
975        let ns = drt.namespace("test_reconciliation".to_string()).unwrap();
976        let component = ns.component("test_component".to_string()).unwrap();
977        let endpoint = component.endpoint("test_endpoint".to_string());
978
979        // Use a short reconcile interval for faster tests
980        let client = Client::with_reconcile_interval(endpoint, TEST_RECONCILE_INTERVAL)
981            .await
982            .unwrap();
983
984        // Initially, instance_avail should be empty (no registered instances)
985        assert!(client.instance_ids_avail().is_empty());
986
987        // For this test, we'll directly manipulate instance_avail and verify reconciliation
988        // Store some test IDs
989        client.override_instance_avail(vec![1, 2, 3]);
990
991        assert_eq!(client.instance_ids_avail(), vec![1u64, 2, 3]);
992
993        // Simulate report_instance_down removing instance 2
994        client.report_instance_down(2);
995        assert_eq!(client.instance_ids_avail(), vec![1u64, 3]);
996
997        // Wait for reconciliation interval + buffer
998        // The monitor_instance_source will reset instance_avail to match instance_source
999        // Since instance_source is empty, after reconciliation instance_avail should be empty
1000        tokio::time::sleep(TEST_RECONCILE_INTERVAL + Duration::from_millis(50)).await;
1001
1002        // After reconciliation, instance_avail should match instance_source (which is empty)
1003        assert!(
1004            client.instance_ids_avail().is_empty(),
1005            "After reconciliation, instance_avail should match instance_source"
1006        );
1007
1008        rt.shutdown();
1009    }
1010
1011    /// A zero inhibited duration disables local worker inhibition.
1012    #[tokio::test]
1013    async fn test_zero_inhibited_duration_leaves_instance_routable() {
1014        let rt = Runtime::from_current().unwrap();
1015        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1016            .await
1017            .unwrap();
1018        let ns = drt
1019            .namespace("test_disabled_inhibition".to_string())
1020            .unwrap();
1021        let component = ns.component("test_component".to_string()).unwrap();
1022        let endpoint = component.endpoint("test_endpoint".to_string());
1023
1024        let client = Client::with_reconcile_interval(endpoint, Duration::ZERO)
1025            .await
1026            .unwrap();
1027
1028        client.override_instance_avail(vec![1, 2, 3]);
1029        client.report_instance_down(2);
1030
1031        assert_eq!(
1032            client.instance_ids_avail(),
1033            vec![1, 2, 3],
1034            "a zero inhibited duration should leave the reported instance routable"
1035        );
1036
1037        rt.shutdown();
1038    }
1039
1040    /// Test that report_instance_down correctly removes an instance from instance_avail.
1041    #[tokio::test]
1042    async fn test_report_instance_down() {
1043        let rt = Runtime::from_current().unwrap();
1044        // Use process_local config to avoid needing etcd/nats
1045        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1046            .await
1047            .unwrap();
1048        let ns = drt.namespace("test_report_down".to_string()).unwrap();
1049        let component = ns.component("test_component".to_string()).unwrap();
1050        let endpoint = component.endpoint("test_endpoint".to_string());
1051
1052        let client = endpoint.client().await.unwrap();
1053
1054        // Manually set up instance_avail with test instances
1055        client.override_instance_avail(vec![1, 2, 3]);
1056        assert_eq!(client.instance_ids_avail(), vec![1u64, 2, 3]);
1057
1058        // Report instance 2 as down
1059        client.report_instance_down(2);
1060
1061        // Verify instance 2 is removed
1062        let avail = client.instance_ids_avail();
1063        assert!(avail.contains(&1), "Instance 1 should still be available");
1064        assert!(
1065            !avail.contains(&2),
1066            "Instance 2 should be removed after report_instance_down"
1067        );
1068        assert!(avail.contains(&3), "Instance 3 should still be available");
1069
1070        rt.shutdown();
1071    }
1072
1073    #[tokio::test]
1074    async fn test_overloaded_instance_ids_returns_none_when_empty() {
1075        let rt = Runtime::from_current().unwrap();
1076        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1077            .await
1078            .unwrap();
1079        let ns = drt.namespace("test_overloaded_ids".to_string()).unwrap();
1080        let component = ns.component("test_component".to_string()).unwrap();
1081        let endpoint = component.endpoint("test_endpoint".to_string());
1082        let client = endpoint.client().await.unwrap();
1083
1084        assert_eq!(client.overloaded_instance_ids(), None);
1085
1086        assert!(client.set_overloaded_instances(&[7]));
1087        assert_eq!(client.overloaded_instance_ids(), Some(HashSet::from([7])));
1088        assert!(!client.set_overloaded_instances(&[7]));
1089
1090        assert!(client.set_overloaded_instances(&[]));
1091        assert_eq!(client.overloaded_instance_ids(), None);
1092        assert!(!client.set_overloaded_instances(&[]));
1093
1094        rt.shutdown();
1095    }
1096
1097    #[tokio::test]
1098    async fn test_instance_reconciliation_preserves_overloaded_existing_instances() {
1099        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1100
1101        let rt = Runtime::from_current().unwrap();
1102        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1103            .await
1104            .unwrap();
1105        let ns = drt
1106            .namespace("test_overloaded_reconciliation".to_string())
1107            .unwrap();
1108        let component = ns.component("test_component".to_string()).unwrap();
1109        let endpoint = component.endpoint("test_endpoint".to_string());
1110
1111        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1112            .await
1113            .unwrap();
1114        endpoint.register_endpoint_instance().await.unwrap();
1115        let instances = client.wait_for_instances().await.unwrap();
1116        let worker_id = instances[0].id();
1117
1118        for _ in 0..10 {
1119            if client.instance_ids_free().contains(&worker_id) {
1120                break;
1121            }
1122            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1123        }
1124        assert!(
1125            client.instance_ids_free().contains(&worker_id),
1126            "worker should be free after initial discovery reconciliation"
1127        );
1128
1129        client.set_overloaded_instances(&[worker_id]);
1130        assert!(
1131            client.instance_ids_free().is_empty(),
1132            "worker should be overloaded before periodic reconciliation"
1133        );
1134
1135        tokio::time::sleep(TEST_RECONCILE_INTERVAL + Duration::from_millis(50)).await;
1136
1137        assert!(
1138            client.instance_ids_free().is_empty(),
1139            "periodic reconciliation should not mark an existing overloaded worker free"
1140        );
1141
1142        rt.shutdown();
1143    }
1144
1145    #[tokio::test]
1146    async fn test_report_instance_down_preserves_overloaded_state() {
1147        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1148
1149        let rt = Runtime::from_current().unwrap();
1150        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1151            .await
1152            .unwrap();
1153        let ns = drt
1154            .namespace("test_report_down_preserves_overloaded".to_string())
1155            .unwrap();
1156        let component = ns.component("test_component".to_string()).unwrap();
1157        let endpoint = component.endpoint("test_endpoint".to_string());
1158
1159        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1160            .await
1161            .unwrap();
1162        endpoint.register_endpoint_instance().await.unwrap();
1163        let instances = client.wait_for_instances().await.unwrap();
1164        let worker_id = instances[0].id();
1165
1166        for _ in 0..10 {
1167            if client.instance_ids_avail().contains(&worker_id) {
1168                break;
1169            }
1170            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1171        }
1172
1173        client.set_overloaded_instances(&[worker_id]);
1174        client.report_instance_down(worker_id);
1175
1176        assert!(
1177            !client.instance_ids_avail().contains(&worker_id),
1178            "reported-down worker should leave routable availability"
1179        );
1180        assert_eq!(
1181            client.routing_instance_counts().overloaded,
1182            1,
1183            "reported-down worker should remain overloaded while still discovered"
1184        );
1185        assert!(
1186            client.instance_ids_free().is_empty(),
1187            "reported-down overloaded worker should not become free"
1188        );
1189
1190        endpoint.unregister_endpoint_instance().await.unwrap();
1191        for _ in 0..10 {
1192            if client.routing_instance_counts().overloaded == 0 {
1193                break;
1194            }
1195            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1196        }
1197
1198        assert_eq!(
1199            client.routing_instance_counts().overloaded,
1200            0,
1201            "stable discovery removal should clear overloaded state"
1202        );
1203
1204        rt.shutdown();
1205    }
1206
1207    #[tokio::test]
1208    async fn test_instance_reconciliation_prunes_removed_overloaded_instances() {
1209        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1210
1211        let rt = Runtime::from_current().unwrap();
1212        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1213            .await
1214            .unwrap();
1215        let ns = drt
1216            .namespace("test_removed_overloaded_cleanup".to_string())
1217            .unwrap();
1218        let component = ns.component("test_component".to_string()).unwrap();
1219        let endpoint = component.endpoint("test_endpoint".to_string());
1220
1221        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1222            .await
1223            .unwrap();
1224        endpoint.register_endpoint_instance().await.unwrap();
1225        let instances = client.wait_for_instances().await.unwrap();
1226        let worker_id = instances[0].id();
1227
1228        client.set_overloaded_instances(&[worker_id]);
1229        assert_eq!(client.routing_instance_counts().overloaded, 1);
1230        assert!(client.instance_ids_free().is_empty());
1231
1232        endpoint.unregister_endpoint_instance().await.unwrap();
1233        for _ in 0..10 {
1234            if client.routing_instance_counts().overloaded == 0 {
1235                break;
1236            }
1237            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1238        }
1239
1240        assert_eq!(
1241            client.routing_instance_counts().overloaded,
1242            0,
1243            "removed discovered workers should not remain in overloaded state"
1244        );
1245
1246        rt.shutdown();
1247    }
1248
1249    #[tokio::test]
1250    async fn test_instance_ids_free_excludes_overloaded_new_instances() {
1251        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1252
1253        let rt = Runtime::from_current().unwrap();
1254        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1255            .await
1256            .unwrap();
1257        let worker_id = drt.connection_id();
1258        let ns = drt
1259            .namespace("test_new_overloaded_reconciliation".to_string())
1260            .unwrap();
1261        let component = ns.component("test_component".to_string()).unwrap();
1262        let endpoint = component.endpoint("test_endpoint".to_string());
1263
1264        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1265            .await
1266            .unwrap();
1267        client.set_overloaded_instances(&[worker_id]);
1268
1269        endpoint.register_endpoint_instance().await.unwrap();
1270        let instances = client.wait_for_instances().await.unwrap();
1271        assert_eq!(instances[0].id(), worker_id);
1272        assert!(
1273            client.instance_ids_free().is_empty(),
1274            "newly discovered overloaded worker should not be free"
1275        );
1276
1277        tokio::time::sleep(TEST_RECONCILE_INTERVAL + Duration::from_millis(50)).await;
1278
1279        assert!(
1280            client.instance_ids_free().is_empty(),
1281            "discovery reconciliation should not affect recomputed free workers"
1282        );
1283
1284        rt.shutdown();
1285    }
1286
1287    #[tokio::test]
1288    async fn test_discovery_add_updates_free_without_overloaded_publish() {
1289        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1290
1291        let rt = Runtime::from_current().unwrap();
1292        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1293            .await
1294            .unwrap();
1295        let ns = drt
1296            .namespace("test_free_updates_on_discovery_add".to_string())
1297            .unwrap();
1298        let component = ns.component("test_component".to_string()).unwrap();
1299        let endpoint = component.endpoint("test_endpoint".to_string());
1300
1301        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1302            .await
1303            .unwrap();
1304        endpoint.register_endpoint_instance().await.unwrap();
1305        let instances = client.wait_for_instances().await.unwrap();
1306        let worker_id = instances[0].id();
1307
1308        for _ in 0..10 {
1309            if client.instance_ids_free().contains(&worker_id) {
1310                break;
1311            }
1312            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1313        }
1314
1315        assert_eq!(
1316            client.instance_ids_free(),
1317            vec![worker_id],
1318            "newly discovered non-overloaded workers should appear free without an overload update"
1319        );
1320
1321        rt.shutdown();
1322    }
1323
1324    /// Test that instance_avail_watcher receives updates when instances change.
1325    #[tokio::test]
1326    async fn test_instance_avail_watcher() {
1327        let rt = Runtime::from_current().unwrap();
1328        // Use process_local config to avoid needing etcd/nats
1329        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1330            .await
1331            .unwrap();
1332        let ns = drt.namespace("test_watcher".to_string()).unwrap();
1333        let component = ns.component("test_component".to_string()).unwrap();
1334        let endpoint = component.endpoint("test_endpoint".to_string());
1335
1336        let client = endpoint.client().await.unwrap();
1337        let watcher = client.instance_avail_watcher();
1338
1339        // Set initial instances
1340        client.override_instance_avail(vec![1, 2, 3]);
1341
1342        // Report instance down - this should notify the watcher
1343        client.report_instance_down(2);
1344
1345        // The watcher should receive the update
1346        // Note: We need to check if changed() was signaled
1347        let current = watcher.borrow().clone();
1348        assert_eq!(current, vec![1, 3]);
1349
1350        rt.shutdown();
1351    }
1352
1353    /// Test that concurrent select_and_increment distributes load correctly.
1354    #[tokio::test]
1355    async fn test_concurrent_select_and_increment() {
1356        let state = Arc::new(RoutingOccupancyState::default());
1357        let instance_ids: Vec<u64> = vec![100, 200, 300];
1358        let num_requests = 90;
1359
1360        let mut handles = Vec::new();
1361        for _ in 0..num_requests {
1362            let state = state.clone();
1363            let ids = instance_ids.clone();
1364            handles.push(tokio::spawn(async move {
1365                state.select_exact_min_and_increment(&ids).await
1366            }));
1367        }
1368
1369        for handle in handles {
1370            handle.await.unwrap();
1371        }
1372
1373        assert_eq!(state.load(100), 30);
1374        assert_eq!(state.load(200), 30);
1375        assert_eq!(state.load(300), 30);
1376    }
1377
1378    #[tokio::test]
1379    async fn test_select_exact_min_and_increment_randomizes_ties() {
1380        let mut selected = [false; 3];
1381
1382        for _ in 0..120 {
1383            let state = RoutingOccupancyState::default();
1384            let picked = state
1385                .select_exact_min_and_increment(&[10, 20, 30])
1386                .await
1387                .unwrap();
1388            match picked {
1389                10 => selected[0] = true,
1390                20 => selected[1] = true,
1391                30 => selected[2] = true,
1392                _ => panic!("unexpected worker id: {picked}"),
1393            }
1394        }
1395
1396        let selected_count = selected.into_iter().filter(|seen| *seen).count();
1397        assert!(
1398            selected_count > 1,
1399            "tie-breaking should not always select the first minimum-load worker"
1400        );
1401    }
1402
1403    #[tokio::test]
1404    async fn test_connection_counts() {
1405        let rt = Runtime::from_current().unwrap();
1406        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1407            .await
1408            .unwrap();
1409        let ns = drt.namespace("test_ll_counts".to_string()).unwrap();
1410        let component = ns.component("test_component".to_string()).unwrap();
1411        let endpoint = component.endpoint("test_endpoint".to_string());
1412
1413        let state1 = get_or_create_routing_occupancy_state(&endpoint).await;
1414        let state2 = get_or_create_routing_occupancy_state(&endpoint).await;
1415
1416        let picked1 = state1
1417            .select_exact_min_and_increment(&[10, 20, 30])
1418            .await
1419            .unwrap();
1420        assert_eq!(state1.load(picked1), 1);
1421
1422        let picked2 = state1
1423            .select_exact_min_and_increment(&[10, 20, 30])
1424            .await
1425            .unwrap();
1426        assert_ne!(picked1, picked2);
1427
1428        // state2 should see the same counts (same underlying Arc)
1429        assert_eq!(state2.load(10), state1.load(10));
1430        assert_eq!(state2.load(20), state1.load(20));
1431        assert_eq!(state2.load(30), state1.load(30));
1432
1433        state2.decrement(picked1);
1434        assert_eq!(state1.load(picked1), if picked1 == picked2 { 1 } else { 0 });
1435
1436        rt.shutdown();
1437    }
1438
1439    #[tokio::test]
1440    async fn test_least_loaded_state_retain() {
1441        let state = RoutingOccupancyState::default();
1442
1443        // Add some connections
1444        state.select_exact_min_and_increment(&[1, 2, 3]).await;
1445        state.select_exact_min_and_increment(&[1, 2, 3]).await;
1446        state.select_exact_min_and_increment(&[1, 2, 3]).await;
1447        // Each instance should have 1 connection
1448        assert_eq!(state.load(1), 1);
1449        assert_eq!(state.load(2), 1);
1450        assert_eq!(state.load(3), 1);
1451
1452        // Retain only instances 1 and 3 (instance 2 was removed)
1453        state.retain(&[1, 3]);
1454
1455        assert_eq!(state.load(1), 1);
1456        assert_eq!(state.load(2), 0);
1457        assert_eq!(state.load(3), 1);
1458    }
1459
1460    #[tokio::test]
1461    async fn test_monitor_instance_source_cleans_up_removed_worker_counts() {
1462        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1463
1464        let rt = Runtime::from_current().unwrap();
1465        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1466            .await
1467            .unwrap();
1468        let ns = drt.namespace("test_occupancy_cleanup".to_string()).unwrap();
1469        let component = ns.component("test_component".to_string()).unwrap();
1470        let endpoint = component.endpoint("test_endpoint".to_string());
1471
1472        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1473            .await
1474            .unwrap();
1475        endpoint.register_endpoint_instance().await.unwrap();
1476        client.wait_for_instances().await.unwrap();
1477
1478        let worker_id = client.instance_ids_avail()[0];
1479        let state = get_or_create_routing_occupancy_state(&endpoint).await;
1480        state.increment(worker_id);
1481        assert_eq!(state.load(worker_id), 1);
1482
1483        endpoint.unregister_endpoint_instance().await.unwrap();
1484
1485        for _ in 0..10 {
1486            if state.load(worker_id) == 0 {
1487                break;
1488            }
1489            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1490        }
1491
1492        assert_eq!(state.load(worker_id), 0);
1493
1494        rt.shutdown();
1495    }
1496}