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::{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    instance_avail_tx: tokio::sync::watch::Sender<Vec<u64>>,
382}
383
384impl RoutingInstancesState {
385    fn new(discovered_ids: Vec<u64>) -> (Self, tokio::sync::watch::Receiver<Vec<u64>>) {
386        let snapshot = RoutingInstances::new(discovered_ids);
387        let (instance_avail_tx, instance_avail_rx) =
388            tokio::sync::watch::channel(snapshot.routable_ids().to_vec());
389        (
390            Self {
391                snapshot: ArcSwap::from_pointee(snapshot),
392                update_lock: StdMutex::new(()),
393                instance_avail_tx,
394            },
395            instance_avail_rx,
396        )
397    }
398
399    fn snapshot(&self) -> arc_swap::Guard<Arc<RoutingInstances>> {
400        self.snapshot.load()
401    }
402
403    fn update(
404        &self,
405        update: impl FnOnce(&RoutingInstances) -> RoutingInstances,
406        publish_routable_ids: bool,
407    ) -> Arc<RoutingInstances> {
408        let _guard = self.update_lock.lock().unwrap();
409        let current = self.snapshot.load();
410        let next = Arc::new(update(&current));
411        self.snapshot.store(next.clone());
412        if publish_routable_ids {
413            self.publish_routable_ids(&next);
414        }
415        next
416    }
417
418    fn publish_routable_ids(&self, routing_instances: &RoutingInstances) {
419        let _ = self
420            .instance_avail_tx
421            .send(routing_instances.routable_ids().to_vec());
422    }
423
424    fn routable_ids(&self) -> Vec<u64> {
425        self.snapshot().routable_ids().to_vec()
426    }
427
428    fn free_ids(&self) -> Vec<u64> {
429        self.snapshot().free_ids.clone()
430    }
431
432    fn counts(&self) -> RoutingInstanceCounts {
433        self.snapshot().counts()
434    }
435
436    fn overloaded_ids(&self) -> Option<HashSet<u64>> {
437        self.snapshot().overloaded_ids()
438    }
439
440    fn report_instance_down(&self, instance_id: u64) {
441        self.update(|current| current.report_instance_down(instance_id), true);
442    }
443
444    fn set_overloaded_instances(&self, overloaded_instance_ids: &[u64]) -> bool {
445        let overloaded_ids = overloaded_instance_ids
446            .iter()
447            .copied()
448            .collect::<HashSet<_>>();
449        let _guard = self.update_lock.lock().unwrap();
450        let current = self.snapshot.load();
451        if current.overloaded_ids == overloaded_ids {
452            return false;
453        }
454
455        let next = Arc::new(current.set_overloaded(overloaded_ids));
456        self.snapshot.store(next);
457        true
458    }
459
460    fn mark_overloaded_immediate(&self, instance_id: u64) {
461        self.update(
462            move |current| current.mark_overloaded(instance_id),
463            // Routable set is unchanged — only the derived free set shrinks —
464            // so there's no need to republish routable_ids.
465            false,
466        );
467    }
468
469    fn clear_overloaded_for_removed(&self, removed_instance_ids: &[u64]) {
470        if removed_instance_ids.is_empty() {
471            return;
472        }
473
474        let removed_ids = removed_instance_ids.iter().copied().collect::<HashSet<_>>();
475        self.update(
476            move |current| current.clear_overloaded_for_removed(&removed_ids),
477            false,
478        );
479    }
480
481    fn reconcile_discovered(&self, discovered_ids: Vec<u64>) -> Arc<RoutingInstances> {
482        self.update(
483            move |current| current.reconcile_discovered(discovered_ids),
484            true,
485        )
486    }
487
488    #[cfg(test)]
489    fn override_routable_ids(&self, ids: Vec<u64>) {
490        self.update(move |current| current.override_routable_ids(ids), true);
491    }
492}
493
494#[derive(Clone, Debug)]
495pub struct Client {
496    // This is me
497    pub endpoint: Endpoint,
498    // Shared endpoint discovery source backing both snapshots and raw events.
499    endpoint_discovery_source: Arc<EndpointDiscoverySource>,
500    // These are the remotes I know about from watching key-value store
501    pub instance_source: Arc<tokio::sync::watch::Receiver<Vec<Instance>>>,
502    // Immutable routing snapshot. Free IDs are derived from discovered IDs and overloaded IDs.
503    routing_instances: Arc<RoutingInstancesState>,
504    // Client clones and standalone watchers jointly own the reconciliation task.
505    instance_avail_owner: Arc<tokio::sync::watch::Receiver<Vec<u64>>>,
506    /// Interval for periodic reconciliation of instance_avail with instance_source.
507    /// This ensures instances removed via `report_instance_down` are eventually restored.
508    /// A zero value disables local worker inhibition.
509    reconcile_interval: Duration,
510}
511
512impl Client {
513    // Client with auto-discover instances using key-value store
514    pub(crate) async fn new(endpoint: Endpoint) -> Result<Self> {
515        Self::with_reconcile_interval(endpoint, *INHIBITED_DURATION).await
516    }
517
518    /// Create a client with a custom reconcile interval.
519    /// The reconcile interval controls how often `instance_avail` is reset to match
520    /// `instance_source`, restoring any instances removed via `report_instance_down`.
521    pub(crate) async fn with_reconcile_interval(
522        endpoint: Endpoint,
523        reconcile_interval: Duration,
524    ) -> Result<Self> {
525        tracing::trace!(
526            "Client::new_dynamic: Creating dynamic client for endpoint: {}",
527            endpoint.id()
528        );
529        let endpoint_discovery_source =
530            Self::get_or_create_dynamic_discovery_source(&endpoint).await?;
531        let instance_source = Arc::new(endpoint_discovery_source.instance_receiver());
532
533        // Seed instance_avail from the current instance_source snapshot so that
534        // callers who proceed immediately after wait_for_instances (which reads
535        // instance_source directly) will also find instances in instance_avail
536        // (which is read by the routing methods like random/round_robin).
537        let initial_ids: Vec<u64> = instance_source
538            .borrow()
539            .iter()
540            .map(|instance| instance.id())
541            .collect();
542        let (routing_instances, instance_avail_owner) = RoutingInstancesState::new(initial_ids);
543        let client = Client {
544            endpoint: endpoint.clone(),
545            endpoint_discovery_source,
546            instance_source: instance_source.clone(),
547            routing_instances: Arc::new(routing_instances),
548            instance_avail_owner: Arc::new(instance_avail_owner),
549            reconcile_interval,
550        };
551        client.monitor_instance_source();
552        Ok(client)
553    }
554
555    /// Instances available from watching key-value store
556    pub fn instances(&self) -> Vec<Instance> {
557        self.instance_source.borrow().clone()
558    }
559
560    pub fn instance_ids(&self) -> Vec<u64> {
561        self.instances().into_iter().map(|ep| ep.id()).collect()
562    }
563
564    pub fn instance_ids_avail(&self) -> Vec<u64> {
565        self.routing_instances.routable_ids()
566    }
567
568    /// Routable instance ids excluding those currently flagged overloaded — the set used
569    /// for load-aware (random / round-robin) worker selection.
570    pub fn instance_ids_free(&self) -> Vec<u64> {
571        self.routing_instances.free_ids()
572    }
573
574    pub(crate) fn routing_instances(&self) -> arc_swap::Guard<Arc<RoutingInstances>> {
575        self.routing_instances.snapshot()
576    }
577
578    pub fn routing_instance_counts(&self) -> RoutingInstanceCounts {
579        self.routing_instances.counts()
580    }
581
582    /// Get a watcher for available instance IDs
583    pub fn instance_avail_watcher(&self) -> tokio::sync::watch::Receiver<Vec<u64>> {
584        self.instance_avail_owner.as_ref().clone()
585    }
586
587    /// Subscribe to raw discovery events for this endpoint.
588    ///
589    /// Unlike `instance_source`, this feed does not coalesce remove→add pairs,
590    /// so consumers can react to every removal event exactly once.
591    pub(crate) fn subscribe_discovery_events(&self) -> DiscoveryEventReceiver {
592        DiscoveryEventReceiver {
593            receiver: self.endpoint_discovery_source.subscribe_events(),
594            _source: self.endpoint_discovery_source.clone(),
595        }
596    }
597
598    /// Wait for at least one Instance to be available for this Endpoint
599    pub async fn wait_for_instances(&self) -> Result<Vec<Instance>> {
600        tracing::trace!(
601            "wait_for_instances: Starting wait for endpoint: {}",
602            self.endpoint.id()
603        );
604        let mut rx = self.instance_source.as_ref().clone();
605        // wait for there to be 1 or more endpoints
606        let mut instances: Vec<Instance>;
607        loop {
608            instances = rx.borrow_and_update().to_vec();
609            if instances.is_empty() {
610                rx.changed().await?;
611            } else {
612                tracing::info!(
613                    "wait_for_instances: Found {} instance(s) for endpoint: {}",
614                    instances.len(),
615                    self.endpoint.id()
616                );
617                break;
618            }
619        }
620        Ok(instances)
621    }
622
623    /// Mark an instance as down/unavailable
624    pub fn report_instance_down(&self, instance_id: u64) {
625        if self.reconcile_interval.is_zero() {
626            tracing::debug!(
627                instance_id,
628                "local worker inhibition is disabled; leaving instance routable"
629            );
630            return;
631        }
632
633        self.routing_instances.report_instance_down(instance_id);
634        tracing::debug!("inhibiting instance {instance_id}");
635    }
636
637    /// Replace the set of overloaded instances reported by the worker monitor.
638    /// Returns true when this changes the routing snapshot.
639    pub fn set_overloaded_instances(&self, overloaded_instance_ids: &[u64]) -> bool {
640        self.routing_instances
641            .set_overloaded_instances(overloaded_instance_ids)
642    }
643
644    /// Mark an instance overloaded immediately. A worker returning
645    /// `ResourceExhausted` is busy ("queue full, retry later"), not faulted, so
646    /// this is the overload path, NOT `report_instance_down`. Short-lived: the
647    /// next `set_overloaded_instances` recompute overwrites the overloaded set.
648    pub fn mark_overloaded_immediate(&self, instance_id: u64) {
649        self.routing_instances
650            .mark_overloaded_immediate(instance_id);
651        tracing::debug!(
652            instance_id,
653            "marking instance overloaded (backpressure); next metric event will re-evaluate"
654        );
655    }
656
657    pub fn clear_overloaded_instances_for_removed(&self, removed_instance_ids: &[u64]) {
658        self.routing_instances
659            .clear_overloaded_for_removed(removed_instance_ids);
660    }
661
662    pub fn overloaded_instance_ids(&self) -> Option<HashSet<u64>> {
663        self.routing_instances.overloaded_ids()
664    }
665
666    /// Monitor the key-value instance source and update instance_avail.
667    ///
668    /// This function also performs periodic reconciliation: if `instance_source` hasn't
669    /// changed for `reconcile_interval`, we reset `instance_avail` to match
670    /// `instance_source`. This ensures instances removed via `report_instance_down`
671    /// are eventually restored even if the discovery source doesn't emit updates.
672    fn monitor_instance_source(&self) {
673        let reconcile_interval = self.reconcile_interval;
674        let cancel_token = self.endpoint.drt().primary_token();
675        let endpoint = self.endpoint.clone();
676        let endpoint_discovery_source = self.endpoint_discovery_source.clone();
677        let routing_instances = self.routing_instances.clone();
678        let instance_source = self.instance_source.clone();
679        let endpoint_id = self.endpoint.id();
680        tokio::task::spawn(async move {
681            let mut rx = instance_source.as_ref().clone();
682            while !cancel_token.is_cancelled() {
683                let instance_ids: Vec<u64> = rx
684                    .borrow_and_update()
685                    .iter()
686                    .map(|instance| instance.id())
687                    .collect();
688
689                let snapshot = routing_instances.reconcile_discovered(instance_ids);
690
691                // Clean up stale occupancy counters for instances that no longer exist.
692                let registry = endpoint.drt().routing_occupancy_states();
693                if let Ok(registry) = registry.try_lock()
694                    && let Some(weak) = registry.get(&endpoint)
695                    && let Some(state) = weak.upgrade()
696                {
697                    state.retain(snapshot.discovered_ids());
698                }
699
700                tokio::select! {
701                    _ = cancel_token.cancelled() => break,
702                    _ = routing_instances.instance_avail_tx.closed() => break,
703                    result = rx.changed() => {
704                        if let Err(err) = result {
705                            tracing::error!(
706                                "monitor_instance_source: The Sender is dropped: {err}, endpoint={endpoint_id}",
707                            );
708                            cancel_token.cancel();
709                        }
710                    }
711                    _ = tokio::time::sleep(reconcile_interval), if !reconcile_interval.is_zero() => {
712                        tracing::trace!(
713                            "monitor_instance_source: periodic reconciliation for endpoint={endpoint_id}",
714                        );
715                    }
716                }
717            }
718            drop(endpoint_discovery_source);
719        });
720    }
721
722    /// Override routable IDs for testing. This allows creating an inconsistency
723    /// between `instance_ids_avail()` and `instances()` to simulate downed workers.
724    #[cfg(test)]
725    pub(crate) fn override_instance_avail(&self, ids: Vec<u64>) {
726        self.routing_instances.override_routable_ids(ids);
727    }
728
729    async fn get_or_create_dynamic_discovery_source(
730        endpoint: &Endpoint,
731    ) -> Result<Arc<EndpointDiscoverySource>> {
732        let drt = endpoint.drt();
733        let sources = drt.endpoint_discovery_sources();
734        let mut sources = sources.lock().await;
735
736        if let Some(source) = sources.get(endpoint) {
737            if let Some(source) = source.upgrade() {
738                return Ok(source);
739            } else {
740                sources.remove(endpoint);
741            }
742        }
743
744        let discovery = drt.discovery();
745        let discovery_query = crate::discovery::DiscoveryQuery::Endpoint {
746            namespace: endpoint.component.namespace.name.clone(),
747            component: endpoint.component.name.clone(),
748            endpoint: endpoint.name.clone(),
749        };
750
751        let mut discovery_stream = discovery
752            .list_and_watch(discovery_query.clone(), None)
753            .await?;
754        let (watch_tx, watch_rx) = tokio::sync::watch::channel(vec![]);
755        let discovery_source = Arc::new(EndpointDiscoverySource::new(watch_rx));
756
757        let secondary = endpoint.component.drt.runtime().secondary().clone();
758        let discovery_source_task = Arc::downgrade(&discovery_source);
759
760        secondary.spawn(async move {
761            tracing::trace!("endpoint_watcher: Starting for discovery query: {:?}", discovery_query);
762            let mut map: HashMap<u64, Instance> = HashMap::new();
763
764            loop {
765                let discovery_event = tokio::select! {
766                    _ = watch_tx.closed() => {
767                        break;
768                    }
769                    discovery_event = discovery_stream.next() => {
770                        match discovery_event {
771                            Some(Ok(event)) => {
772                                event
773                            },
774                            Some(Err(e)) => {
775                                tracing::error!("endpoint_watcher: discovery stream error: {}; shutting down for discovery query: {:?}", e, discovery_query);
776                                break;
777                            }
778                            None => {
779                                break;
780                            }
781                        }
782                    }
783                };
784
785                if let Some(discovery_source) = discovery_source_task.upgrade() {
786                    discovery_source.broadcast_event(&discovery_event);
787                }
788
789                match discovery_event {
790                    DiscoveryEvent::Added(DiscoveryInstance::Endpoint(instance)) => {
791                        map.insert(instance.instance_id, instance);
792                    }
793                    DiscoveryEvent::Added(_) => {}
794                    DiscoveryEvent::Removed(id) => {
795                        if let DiscoveryInstanceId::Endpoint(endpoint_id) = id {
796                            map.remove(&endpoint_id.instance_id);
797                        }
798                    }
799                }
800
801                let instances: Vec<Instance> = map.values().cloned().collect();
802                if watch_tx.send(instances).is_err() {
803                    break;
804                }
805            }
806            let _ = watch_tx.send(vec![]);
807        });
808
809        sources.insert(endpoint.clone(), Arc::downgrade(&discovery_source));
810        Ok(discovery_source)
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817    use crate::{DistributedRuntime, Runtime, distributed::DistributedConfig};
818
819    async fn wait_for_discovery_event(
820        receiver: &mut DiscoveryEventReceiver,
821        predicate: impl Fn(&DiscoveryEvent) -> bool,
822    ) {
823        tokio::time::timeout(Duration::from_secs(1), async {
824            loop {
825                let event = receiver.recv().await.expect("discovery event feed closed");
826                if predicate(&event) {
827                    return;
828                }
829            }
830        })
831        .await
832        .expect("expected discovery event was not received");
833    }
834
835    async fn wait_for_watch_state<T>(
836        receiver: &mut tokio::sync::watch::Receiver<Vec<T>>,
837        predicate: impl Fn(&[T]) -> bool,
838    ) {
839        tokio::time::timeout(Duration::from_secs(1), async {
840            loop {
841                if predicate(receiver.borrow_and_update().as_slice()) {
842                    return;
843                }
844                receiver
845                    .changed()
846                    .await
847                    .expect("instance availability feed closed");
848            }
849        })
850        .await
851        .expect("expected instance availability state was not observed");
852    }
853
854    #[test]
855    fn test_inhibited_duration_from_env() {
856        assert_eq!(
857            inhibited_duration_from_env(|_| None),
858            Duration::from_secs(DEFAULT_INHIBITED_DURATION_SECS)
859        );
860        assert_eq!(
861            inhibited_duration_from_env(|_| Some("17".to_string())),
862            Duration::from_secs(17)
863        );
864        assert_eq!(
865            inhibited_duration_from_env(|_| Some("0".to_string())),
866            Duration::ZERO
867        );
868        assert_eq!(
869            inhibited_duration_from_env(|_| Some("invalid".to_string())),
870            Duration::from_secs(DEFAULT_INHIBITED_DURATION_SECS)
871        );
872    }
873
874    #[tokio::test]
875    async fn dropping_last_client_releases_routing_state() {
876        let rt = Runtime::from_current().unwrap();
877        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
878            .await
879            .unwrap();
880        let endpoint = drt
881            .namespace("test_client_lifecycle".to_string())
882            .unwrap()
883            .component("test_component".to_string())
884            .unwrap()
885            .endpoint("decode".to_string());
886        let client = endpoint.client().await.unwrap();
887        let routing_instances = Arc::downgrade(&client.routing_instances);
888        let discovery_source = Arc::downgrade(&client.endpoint_discovery_source);
889        let mut raw_watcher = client.instance_source.as_ref().clone();
890        let mut watcher = client.instance_avail_watcher();
891        let mut event_watcher = client.subscribe_discovery_events();
892        raw_watcher.borrow_and_update();
893        watcher.borrow_and_update();
894
895        drop(client);
896        assert!(routing_instances.upgrade().is_some());
897        assert!(discovery_source.upgrade().is_some());
898
899        endpoint.register_endpoint_instance().await.unwrap();
900        wait_for_watch_state(&mut watcher, |instances| instances.len() == 1).await;
901        wait_for_discovery_event(&mut event_watcher, |event| {
902            matches!(event, DiscoveryEvent::Added(DiscoveryInstance::Endpoint(_)))
903        })
904        .await;
905
906        endpoint.unregister_endpoint_instance().await.unwrap();
907        wait_for_watch_state(&mut watcher, |instances| instances.is_empty()).await;
908        assert!(raw_watcher.borrow_and_update().is_empty());
909        wait_for_discovery_event(&mut event_watcher, |event| {
910            matches!(event, DiscoveryEvent::Removed(_))
911        })
912        .await;
913
914        drop(watcher);
915
916        tokio::time::timeout(Duration::from_secs(1), async {
917            while routing_instances.strong_count() != 0 {
918                tokio::task::yield_now().await;
919            }
920        })
921        .await
922        .expect("client monitor retained state after its last observer was dropped");
923        assert!(discovery_source.upgrade().is_some());
924
925        endpoint.register_endpoint_instance().await.unwrap();
926        wait_for_watch_state(&mut raw_watcher, |instances| instances.len() == 1).await;
927        wait_for_discovery_event(&mut event_watcher, |event| {
928            matches!(event, DiscoveryEvent::Added(DiscoveryInstance::Endpoint(_)))
929        })
930        .await;
931        endpoint.unregister_endpoint_instance().await.unwrap();
932        wait_for_watch_state(&mut raw_watcher, |instances| instances.is_empty()).await;
933        wait_for_discovery_event(&mut event_watcher, |event| {
934            matches!(event, DiscoveryEvent::Removed(_))
935        })
936        .await;
937
938        drop(event_watcher);
939        tokio::time::timeout(Duration::from_secs(1), async {
940            while discovery_source.strong_count() != 0 {
941                tokio::task::yield_now().await;
942            }
943        })
944        .await
945        .expect("discovery event receiver retained its source after being dropped");
946
947        rt.shutdown();
948    }
949
950    /// Test that instances removed via report_instance_down are restored after
951    /// the reconciliation interval elapses.
952    #[tokio::test]
953    async fn test_instance_reconciliation() {
954        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(100);
955
956        let rt = Runtime::from_current().unwrap();
957        // Use process_local config to avoid needing etcd/nats
958        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
959            .await
960            .unwrap();
961        let ns = drt.namespace("test_reconciliation".to_string()).unwrap();
962        let component = ns.component("test_component".to_string()).unwrap();
963        let endpoint = component.endpoint("test_endpoint".to_string());
964
965        // Use a short reconcile interval for faster tests
966        let client = Client::with_reconcile_interval(endpoint, TEST_RECONCILE_INTERVAL)
967            .await
968            .unwrap();
969
970        // Initially, instance_avail should be empty (no registered instances)
971        assert!(client.instance_ids_avail().is_empty());
972
973        // For this test, we'll directly manipulate instance_avail and verify reconciliation
974        // Store some test IDs
975        client.override_instance_avail(vec![1, 2, 3]);
976
977        assert_eq!(client.instance_ids_avail(), vec![1u64, 2, 3]);
978
979        // Simulate report_instance_down removing instance 2
980        client.report_instance_down(2);
981        assert_eq!(client.instance_ids_avail(), vec![1u64, 3]);
982
983        // Wait for reconciliation interval + buffer
984        // The monitor_instance_source will reset instance_avail to match instance_source
985        // Since instance_source is empty, after reconciliation instance_avail should be empty
986        tokio::time::sleep(TEST_RECONCILE_INTERVAL + Duration::from_millis(50)).await;
987
988        // After reconciliation, instance_avail should match instance_source (which is empty)
989        assert!(
990            client.instance_ids_avail().is_empty(),
991            "After reconciliation, instance_avail should match instance_source"
992        );
993
994        rt.shutdown();
995    }
996
997    /// A zero inhibited duration disables local worker inhibition.
998    #[tokio::test]
999    async fn test_zero_inhibited_duration_leaves_instance_routable() {
1000        let rt = Runtime::from_current().unwrap();
1001        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1002            .await
1003            .unwrap();
1004        let ns = drt
1005            .namespace("test_disabled_inhibition".to_string())
1006            .unwrap();
1007        let component = ns.component("test_component".to_string()).unwrap();
1008        let endpoint = component.endpoint("test_endpoint".to_string());
1009
1010        let client = Client::with_reconcile_interval(endpoint, Duration::ZERO)
1011            .await
1012            .unwrap();
1013
1014        client.override_instance_avail(vec![1, 2, 3]);
1015        client.report_instance_down(2);
1016
1017        assert_eq!(
1018            client.instance_ids_avail(),
1019            vec![1, 2, 3],
1020            "a zero inhibited duration should leave the reported instance routable"
1021        );
1022
1023        rt.shutdown();
1024    }
1025
1026    /// Test that report_instance_down correctly removes an instance from instance_avail.
1027    #[tokio::test]
1028    async fn test_report_instance_down() {
1029        let rt = Runtime::from_current().unwrap();
1030        // Use process_local config to avoid needing etcd/nats
1031        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1032            .await
1033            .unwrap();
1034        let ns = drt.namespace("test_report_down".to_string()).unwrap();
1035        let component = ns.component("test_component".to_string()).unwrap();
1036        let endpoint = component.endpoint("test_endpoint".to_string());
1037
1038        let client = endpoint.client().await.unwrap();
1039
1040        // Manually set up instance_avail with test instances
1041        client.override_instance_avail(vec![1, 2, 3]);
1042        assert_eq!(client.instance_ids_avail(), vec![1u64, 2, 3]);
1043
1044        // Report instance 2 as down
1045        client.report_instance_down(2);
1046
1047        // Verify instance 2 is removed
1048        let avail = client.instance_ids_avail();
1049        assert!(avail.contains(&1), "Instance 1 should still be available");
1050        assert!(
1051            !avail.contains(&2),
1052            "Instance 2 should be removed after report_instance_down"
1053        );
1054        assert!(avail.contains(&3), "Instance 3 should still be available");
1055
1056        rt.shutdown();
1057    }
1058
1059    #[tokio::test]
1060    async fn test_overloaded_instance_ids_returns_none_when_empty() {
1061        let rt = Runtime::from_current().unwrap();
1062        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1063            .await
1064            .unwrap();
1065        let ns = drt.namespace("test_overloaded_ids".to_string()).unwrap();
1066        let component = ns.component("test_component".to_string()).unwrap();
1067        let endpoint = component.endpoint("test_endpoint".to_string());
1068        let client = endpoint.client().await.unwrap();
1069
1070        assert_eq!(client.overloaded_instance_ids(), None);
1071
1072        assert!(client.set_overloaded_instances(&[7]));
1073        assert_eq!(client.overloaded_instance_ids(), Some(HashSet::from([7])));
1074        assert!(!client.set_overloaded_instances(&[7]));
1075
1076        assert!(client.set_overloaded_instances(&[]));
1077        assert_eq!(client.overloaded_instance_ids(), None);
1078        assert!(!client.set_overloaded_instances(&[]));
1079
1080        rt.shutdown();
1081    }
1082
1083    #[tokio::test]
1084    async fn test_instance_reconciliation_preserves_overloaded_existing_instances() {
1085        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1086
1087        let rt = Runtime::from_current().unwrap();
1088        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1089            .await
1090            .unwrap();
1091        let ns = drt
1092            .namespace("test_overloaded_reconciliation".to_string())
1093            .unwrap();
1094        let component = ns.component("test_component".to_string()).unwrap();
1095        let endpoint = component.endpoint("test_endpoint".to_string());
1096
1097        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1098            .await
1099            .unwrap();
1100        endpoint.register_endpoint_instance().await.unwrap();
1101        let instances = client.wait_for_instances().await.unwrap();
1102        let worker_id = instances[0].id();
1103
1104        for _ in 0..10 {
1105            if client.instance_ids_free().contains(&worker_id) {
1106                break;
1107            }
1108            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1109        }
1110        assert!(
1111            client.instance_ids_free().contains(&worker_id),
1112            "worker should be free after initial discovery reconciliation"
1113        );
1114
1115        client.set_overloaded_instances(&[worker_id]);
1116        assert!(
1117            client.instance_ids_free().is_empty(),
1118            "worker should be overloaded before periodic reconciliation"
1119        );
1120
1121        tokio::time::sleep(TEST_RECONCILE_INTERVAL + Duration::from_millis(50)).await;
1122
1123        assert!(
1124            client.instance_ids_free().is_empty(),
1125            "periodic reconciliation should not mark an existing overloaded worker free"
1126        );
1127
1128        rt.shutdown();
1129    }
1130
1131    #[tokio::test]
1132    async fn test_report_instance_down_preserves_overloaded_state() {
1133        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1134
1135        let rt = Runtime::from_current().unwrap();
1136        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1137            .await
1138            .unwrap();
1139        let ns = drt
1140            .namespace("test_report_down_preserves_overloaded".to_string())
1141            .unwrap();
1142        let component = ns.component("test_component".to_string()).unwrap();
1143        let endpoint = component.endpoint("test_endpoint".to_string());
1144
1145        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1146            .await
1147            .unwrap();
1148        endpoint.register_endpoint_instance().await.unwrap();
1149        let instances = client.wait_for_instances().await.unwrap();
1150        let worker_id = instances[0].id();
1151
1152        for _ in 0..10 {
1153            if client.instance_ids_avail().contains(&worker_id) {
1154                break;
1155            }
1156            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1157        }
1158
1159        client.set_overloaded_instances(&[worker_id]);
1160        client.report_instance_down(worker_id);
1161
1162        assert!(
1163            !client.instance_ids_avail().contains(&worker_id),
1164            "reported-down worker should leave routable availability"
1165        );
1166        assert_eq!(
1167            client.routing_instance_counts().overloaded,
1168            1,
1169            "reported-down worker should remain overloaded while still discovered"
1170        );
1171        assert!(
1172            client.instance_ids_free().is_empty(),
1173            "reported-down overloaded worker should not become free"
1174        );
1175
1176        endpoint.unregister_endpoint_instance().await.unwrap();
1177        for _ in 0..10 {
1178            if client.routing_instance_counts().overloaded == 0 {
1179                break;
1180            }
1181            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1182        }
1183
1184        assert_eq!(
1185            client.routing_instance_counts().overloaded,
1186            0,
1187            "stable discovery removal should clear overloaded state"
1188        );
1189
1190        rt.shutdown();
1191    }
1192
1193    #[tokio::test]
1194    async fn test_instance_reconciliation_prunes_removed_overloaded_instances() {
1195        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1196
1197        let rt = Runtime::from_current().unwrap();
1198        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1199            .await
1200            .unwrap();
1201        let ns = drt
1202            .namespace("test_removed_overloaded_cleanup".to_string())
1203            .unwrap();
1204        let component = ns.component("test_component".to_string()).unwrap();
1205        let endpoint = component.endpoint("test_endpoint".to_string());
1206
1207        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1208            .await
1209            .unwrap();
1210        endpoint.register_endpoint_instance().await.unwrap();
1211        let instances = client.wait_for_instances().await.unwrap();
1212        let worker_id = instances[0].id();
1213
1214        client.set_overloaded_instances(&[worker_id]);
1215        assert_eq!(client.routing_instance_counts().overloaded, 1);
1216        assert!(client.instance_ids_free().is_empty());
1217
1218        endpoint.unregister_endpoint_instance().await.unwrap();
1219        for _ in 0..10 {
1220            if client.routing_instance_counts().overloaded == 0 {
1221                break;
1222            }
1223            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1224        }
1225
1226        assert_eq!(
1227            client.routing_instance_counts().overloaded,
1228            0,
1229            "removed discovered workers should not remain in overloaded state"
1230        );
1231
1232        rt.shutdown();
1233    }
1234
1235    #[tokio::test]
1236    async fn test_instance_ids_free_excludes_overloaded_new_instances() {
1237        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1238
1239        let rt = Runtime::from_current().unwrap();
1240        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1241            .await
1242            .unwrap();
1243        let worker_id = drt.connection_id();
1244        let ns = drt
1245            .namespace("test_new_overloaded_reconciliation".to_string())
1246            .unwrap();
1247        let component = ns.component("test_component".to_string()).unwrap();
1248        let endpoint = component.endpoint("test_endpoint".to_string());
1249
1250        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1251            .await
1252            .unwrap();
1253        client.set_overloaded_instances(&[worker_id]);
1254
1255        endpoint.register_endpoint_instance().await.unwrap();
1256        let instances = client.wait_for_instances().await.unwrap();
1257        assert_eq!(instances[0].id(), worker_id);
1258        assert!(
1259            client.instance_ids_free().is_empty(),
1260            "newly discovered overloaded worker should not be free"
1261        );
1262
1263        tokio::time::sleep(TEST_RECONCILE_INTERVAL + Duration::from_millis(50)).await;
1264
1265        assert!(
1266            client.instance_ids_free().is_empty(),
1267            "discovery reconciliation should not affect recomputed free workers"
1268        );
1269
1270        rt.shutdown();
1271    }
1272
1273    #[tokio::test]
1274    async fn test_discovery_add_updates_free_without_overloaded_publish() {
1275        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1276
1277        let rt = Runtime::from_current().unwrap();
1278        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1279            .await
1280            .unwrap();
1281        let ns = drt
1282            .namespace("test_free_updates_on_discovery_add".to_string())
1283            .unwrap();
1284        let component = ns.component("test_component".to_string()).unwrap();
1285        let endpoint = component.endpoint("test_endpoint".to_string());
1286
1287        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1288            .await
1289            .unwrap();
1290        endpoint.register_endpoint_instance().await.unwrap();
1291        let instances = client.wait_for_instances().await.unwrap();
1292        let worker_id = instances[0].id();
1293
1294        for _ in 0..10 {
1295            if client.instance_ids_free().contains(&worker_id) {
1296                break;
1297            }
1298            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1299        }
1300
1301        assert_eq!(
1302            client.instance_ids_free(),
1303            vec![worker_id],
1304            "newly discovered non-overloaded workers should appear free without an overload update"
1305        );
1306
1307        rt.shutdown();
1308    }
1309
1310    /// Test that instance_avail_watcher receives updates when instances change.
1311    #[tokio::test]
1312    async fn test_instance_avail_watcher() {
1313        let rt = Runtime::from_current().unwrap();
1314        // Use process_local config to avoid needing etcd/nats
1315        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1316            .await
1317            .unwrap();
1318        let ns = drt.namespace("test_watcher".to_string()).unwrap();
1319        let component = ns.component("test_component".to_string()).unwrap();
1320        let endpoint = component.endpoint("test_endpoint".to_string());
1321
1322        let client = endpoint.client().await.unwrap();
1323        let watcher = client.instance_avail_watcher();
1324
1325        // Set initial instances
1326        client.override_instance_avail(vec![1, 2, 3]);
1327
1328        // Report instance down - this should notify the watcher
1329        client.report_instance_down(2);
1330
1331        // The watcher should receive the update
1332        // Note: We need to check if changed() was signaled
1333        let current = watcher.borrow().clone();
1334        assert_eq!(current, vec![1, 3]);
1335
1336        rt.shutdown();
1337    }
1338
1339    /// Test that concurrent select_and_increment distributes load correctly.
1340    #[tokio::test]
1341    async fn test_concurrent_select_and_increment() {
1342        let state = Arc::new(RoutingOccupancyState::default());
1343        let instance_ids: Vec<u64> = vec![100, 200, 300];
1344        let num_requests = 90;
1345
1346        let mut handles = Vec::new();
1347        for _ in 0..num_requests {
1348            let state = state.clone();
1349            let ids = instance_ids.clone();
1350            handles.push(tokio::spawn(async move {
1351                state.select_exact_min_and_increment(&ids).await
1352            }));
1353        }
1354
1355        for handle in handles {
1356            handle.await.unwrap();
1357        }
1358
1359        assert_eq!(state.load(100), 30);
1360        assert_eq!(state.load(200), 30);
1361        assert_eq!(state.load(300), 30);
1362    }
1363
1364    #[tokio::test]
1365    async fn test_select_exact_min_and_increment_randomizes_ties() {
1366        let mut selected = [false; 3];
1367
1368        for _ in 0..120 {
1369            let state = RoutingOccupancyState::default();
1370            let picked = state
1371                .select_exact_min_and_increment(&[10, 20, 30])
1372                .await
1373                .unwrap();
1374            match picked {
1375                10 => selected[0] = true,
1376                20 => selected[1] = true,
1377                30 => selected[2] = true,
1378                _ => panic!("unexpected worker id: {picked}"),
1379            }
1380        }
1381
1382        let selected_count = selected.into_iter().filter(|seen| *seen).count();
1383        assert!(
1384            selected_count > 1,
1385            "tie-breaking should not always select the first minimum-load worker"
1386        );
1387    }
1388
1389    #[tokio::test]
1390    async fn test_connection_counts() {
1391        let rt = Runtime::from_current().unwrap();
1392        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1393            .await
1394            .unwrap();
1395        let ns = drt.namespace("test_ll_counts".to_string()).unwrap();
1396        let component = ns.component("test_component".to_string()).unwrap();
1397        let endpoint = component.endpoint("test_endpoint".to_string());
1398
1399        let state1 = get_or_create_routing_occupancy_state(&endpoint).await;
1400        let state2 = get_or_create_routing_occupancy_state(&endpoint).await;
1401
1402        let picked1 = state1
1403            .select_exact_min_and_increment(&[10, 20, 30])
1404            .await
1405            .unwrap();
1406        assert_eq!(state1.load(picked1), 1);
1407
1408        let picked2 = state1
1409            .select_exact_min_and_increment(&[10, 20, 30])
1410            .await
1411            .unwrap();
1412        assert_ne!(picked1, picked2);
1413
1414        // state2 should see the same counts (same underlying Arc)
1415        assert_eq!(state2.load(10), state1.load(10));
1416        assert_eq!(state2.load(20), state1.load(20));
1417        assert_eq!(state2.load(30), state1.load(30));
1418
1419        state2.decrement(picked1);
1420        assert_eq!(state1.load(picked1), if picked1 == picked2 { 1 } else { 0 });
1421
1422        rt.shutdown();
1423    }
1424
1425    #[tokio::test]
1426    async fn test_least_loaded_state_retain() {
1427        let state = RoutingOccupancyState::default();
1428
1429        // Add some connections
1430        state.select_exact_min_and_increment(&[1, 2, 3]).await;
1431        state.select_exact_min_and_increment(&[1, 2, 3]).await;
1432        state.select_exact_min_and_increment(&[1, 2, 3]).await;
1433        // Each instance should have 1 connection
1434        assert_eq!(state.load(1), 1);
1435        assert_eq!(state.load(2), 1);
1436        assert_eq!(state.load(3), 1);
1437
1438        // Retain only instances 1 and 3 (instance 2 was removed)
1439        state.retain(&[1, 3]);
1440
1441        assert_eq!(state.load(1), 1);
1442        assert_eq!(state.load(2), 0);
1443        assert_eq!(state.load(3), 1);
1444    }
1445
1446    #[tokio::test]
1447    async fn test_monitor_instance_source_cleans_up_removed_worker_counts() {
1448        const TEST_RECONCILE_INTERVAL: Duration = Duration::from_millis(50);
1449
1450        let rt = Runtime::from_current().unwrap();
1451        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1452            .await
1453            .unwrap();
1454        let ns = drt.namespace("test_occupancy_cleanup".to_string()).unwrap();
1455        let component = ns.component("test_component".to_string()).unwrap();
1456        let endpoint = component.endpoint("test_endpoint".to_string());
1457
1458        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
1459            .await
1460            .unwrap();
1461        endpoint.register_endpoint_instance().await.unwrap();
1462        client.wait_for_instances().await.unwrap();
1463
1464        let worker_id = client.instance_ids_avail()[0];
1465        let state = get_or_create_routing_occupancy_state(&endpoint).await;
1466        state.increment(worker_id);
1467        assert_eq!(state.load(worker_id), 1);
1468
1469        endpoint.unregister_endpoint_instance().await.unwrap();
1470
1471        for _ in 0..10 {
1472            if state.load(worker_id) == 0 {
1473                break;
1474            }
1475            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
1476        }
1477
1478        assert_eq!(state.load(worker_id), 0);
1479
1480        rt.shutdown();
1481    }
1482}